diff --git a/docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md b/docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md new file mode 100644 index 00000000..dfc90229 --- /dev/null +++ b/docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md @@ -0,0 +1,1737 @@ +# 타입 안전 gRPC Advanced Capability Expansion Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stable Unary·Server Streaming 플랫폼을 변경하지 않고 Edition 2024/2026, Client/Bidirectional Streaming, Manual Flow Control, Hedging, Custom Resolver·Load Balancer, xDS, gRPC-Web, Servlet, Spring Integration, Reactor·Kotlin과 Channel Diagnostics를 독립적으로 검증·승격할 수 있는 Advanced capability 계층을 구축한다. + +**Architecture:** 모든 Advanced 기능은 `modules/grpc-advanced`에 격리하고 명시적 feature flag를 요구한다. Stable method policy, deadline, execution evidence, security, status, metadata, observability와 streaming guardrail을 그대로 소비하며 raw gRPC escape hatch를 제공하지 않는다. 각 capability는 자체 Compatibility·Security·Fault·Performance·Soak gate를 통과한 뒤에만 Advanced Stable로 승격된다. + +**Tech Stack:** Java 21, Spring Boot 4.1 BOM, Boot-managed gRPC Java/Protobuf, Protobuf Editions, grpc-xds, grpc-web proxy/Envoy, Servlet HTTP/2, Spring Integration 7.1, Project Reactor, Kotlin coroutines/Flow, Channelz/CSDS, JUnit 5, AssertJ, Testcontainers, Toxiproxy. + +## Global Constraints + +- Stable Task 1–53과 Stable Release Gate가 먼저 완료돼야 한다. +- Advanced module root는 `modules/grpc-advanced`이다. +- Root package는 `io.backend.skeleton.grpc.advanced`이다. +- 모든 capability는 `backend.grpc.advanced.*` 아래의 명시적 feature flag를 요구한다. +- Stable starter는 Advanced module에 compile/runtime dependency를 갖지 않는다. +- Edition 2024는 opt-in이고 Edition 2026은 Watch/Experimental이다. +- Client Streaming과 Bidirectional Streaming은 session·sequence·dedup·resume 계약 없이 활성화하지 않는다. +- Manual Flow Control이 raw StreamObserver를 application에 직접 반환해서는 안 된다. +- Hedging은 READ_ONLY Unary만 허용한다. +- Custom Resolver와 Load Balancer는 Stable channel security·retry owner·observability를 우회하지 않는다. +- xDS는 proxyless GR3 Experimental로 유지한다. +- gRPC-Web은 Unary와 Server Streaming만 지원한다. +- Client Streaming·Bidirectional Streaming의 gRPC-Web 지원을 선언하지 않는다. +- Servlet transport는 Netty certification을 대체하지 않는다. +- Spring Integration bridge는 Generated Stub·Service 타입 안전 계약을 대체하지 않는다. +- Reactor와 Kotlin adapter는 Stable Core 타입과 execution evidence를 유지한다. +- Channelz·CSDS는 관리자 전용이며 credential·metadata·payload를 노출하지 않는다. +- 각 capability는 독립 승격이 가능해야 한다. +- 모든 task는 red-green TDD와 독립 commit으로 끝난다. + +--- + +## Execution Baseline + +```text +Stable Task 1–53 +→ Advanced Task 1–18 +``` + +## Advanced Module Map + +```text +modules/grpc-advanced/ +├── grpc-advanced-bootstrap +├── grpc-edition-2024 +├── grpc-edition-2026-experimental +├── grpc-client-streaming +├── grpc-bidi-streaming +├── grpc-manual-flow-control +├── grpc-hedging +├── grpc-custom-resolver +├── grpc-custom-load-balancer +├── grpc-xds +├── grpc-web +├── grpc-servlet-compat +├── grpc-integration-bridge +├── grpc-reactor +├── grpc-kotlin +└── grpc-channel-diagnostics +``` + +## Capability Classification + +| Capability | Initial grade | Stable default | +|---|---|---:| +| Edition 2024 | Advanced opt-in | No | +| Edition 2026 | Watch/Experimental | No | +| Client Streaming | Advanced | No | +| Bidirectional Streaming | Advanced | No | +| Manual Flow Control | Advanced | No | +| Hedging | Experimental | No | +| Custom Resolver | Advanced | No | +| Custom Load Balancer | Experimental | No | +| xDS | Experimental | No | +| gRPC-Web | Advanced compatibility | No | +| Servlet HTTP/2 | Compatibility | No | +| Spring Integration bridge | Optional | No | +| Reactor adapter | Optional Advanced | No | +| Kotlin adapter | Optional Advanced | No | +| Channelz/CSDS diagnostics | Admin Advanced | No | + +## Delivery Phases + +| Phase | Tasks | Result | +|---|---:|---| +| Boundary·Edition | 1–3 | Feature isolation and schema evaluation lanes | +| Streaming | 4–7 | Client/Bidi session, checkpoint and manual flow control | +| Resilience·Discovery | 8–11 | Hedging, custom resolver/LB and xDS | +| Compatibility Bridges | 12–16 | gRPC-Web, Servlet, Integration, Reactor, Kotlin | +| Diagnostics·Promotion | 17–18 | Admin diagnostics, infrastructure testkit and promotion gate | + +--- +### Task 1: Advanced Module Boundary와 Feature Flag + +**Files:** +- Modify: `settings.gradle.kts` +- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedCapability.java` +- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedFeatureFlags.java` +- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedModuleGuard.java` +- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedCapabilityDisabledException.java` +- Create: `modules/grpc-advanced/build.gradle.kts` +- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/build.gradle.kts` +- Test: `modules/grpc-advanced/grpc-advanced-bootstrap/src/test/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedModuleGuardTest.java` + +**Interfaces:** +- Consumes: Stable Task 53 release evidence와 Spring Boot environment. +- Produces: Advanced·Experimental capability가 Stable starter에 자동 유입되지 않도록 하는 dependency·feature flag 경계. + +**Implementation requirements:** +- 모든 capability는 `backend.grpc.advanced.*` 아래의 명시적 flag를 요구한다. +- Stable starter는 Advanced module에 compile/runtime dependency를 갖지 않는다. +- capability grade는 `ADVANCED_STABLE`, `EXPERIMENTAL`, `WATCH`, `DISABLED`로 구분한다. +- production에서 Experimental capability는 별도 승인 profile 없이는 시작되지 않는다. +- Advanced module은 Stable public types를 소비하지만 Stable guardrail을 우회하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcAdvancedModuleGuardTest { + @org.junit.jupiter.api.Test + void disabledCapabilityCannotStart() { + var guard = new GrpcAdvancedModuleGuard( + GrpcAdvancedFeatureFlags.disabled()); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> guard.requireEnabled( + GrpcAdvancedCapability.XDS)) + .isInstanceOf( + GrpcAdvancedCapabilityDisabledException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-advanced-bootstrap:test --tests 'io.backend.skeleton.grpc.advanced.bootstrap.GrpcAdvancedModuleGuardTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcAdvancedCapability { + EDITION_2024, EDITION_2026, + CLIENT_STREAMING, BIDI_STREAMING, + MANUAL_FLOW_CONTROL, HEDGING, + CUSTOM_RESOLVER, CUSTOM_LOAD_BALANCER, + XDS, GRPC_WEB, SERVLET_COMPAT, + SPRING_INTEGRATION, REACTOR, KOTLIN, + CHANNEL_DIAGNOSTICS +} + +public record GrpcAdvancedFeatureFlags( + java.util.Set enabled) { + public static GrpcAdvancedFeatureFlags disabled() { + return new GrpcAdvancedFeatureFlags(java.util.Set.of()); + } + public boolean isEnabled(GrpcAdvancedCapability capability) { + return enabled.contains(capability); + } +} + +public final class GrpcAdvancedModuleGuard { + private final GrpcAdvancedFeatureFlags flags; + public GrpcAdvancedModuleGuard( + GrpcAdvancedFeatureFlags flags) { + this.flags = flags; + } + public void requireEnabled( + GrpcAdvancedCapability capability) { + if (!flags.isEnabled(capability)) { + throw new GrpcAdvancedCapabilityDisabledException( + capability.name()); + } + } +} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-advanced-bootstrap:test --tests 'io.backend.skeleton.grpc.advanced.bootstrap.GrpcAdvancedModuleGuardTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedCapability.java' 'modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedFeatureFlags.java' 'modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedModuleGuard.java' 'modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedCapabilityDisabledException.java' 'modules/grpc-advanced/build.gradle.kts' 'modules/grpc-advanced/grpc-advanced-bootstrap/build.gradle.kts' 'modules/grpc-advanced/grpc-advanced-bootstrap/src/test/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedModuleGuardTest.java' 'settings.gradle.kts' +git commit -m "build: isolate grpc advanced modules" +``` + +### Task 2: Protobuf Edition 2024 Opt-in Lane + +**Files:** +- Create: `modules/grpc-advanced/grpc-edition-2024/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2024Policy.java` +- Create: `modules/grpc-advanced/grpc-edition-2024/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEditionCompatibilityReport.java` +- Create: `modules/grpc-advanced/grpc-edition-2024/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2024Gate.java` +- Create: `modules/grpc-advanced/grpc-edition-2024/src/main/proto/edition2024/compatibility.proto` +- Create: `modules/grpc-advanced/grpc-edition-2024/buf.yaml` +- Test: `modules/grpc-advanced/grpc-edition-2024/src/test/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2024GateTest.java` + +**Interfaces:** +- Consumes: Stable proto3+optional descriptor, consumer language/toolchain fixtures와 Buf gate. +- Produces: Edition 2024 schema를 Stable 기본값과 분리하여 검증·비교하는 Advanced lane. + +**Implementation requirements:** +- Edition 2024는 module-level opt-in으로만 사용한다. +- Java뿐 아니라 지원 consumer language/toolchain compile evidence를 요구한다. +- proto3+optional과 wire/source/JSON behavior를 비교한다. +- public service를 Edition 2024로 이동하려면 승격 ADR과 consumer migration이 필요하다. +- Edition 2024 failure가 Stable proto3 release를 차단하지 않되 promotion을 차단한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcEdition2024GateTest { + @org.junit.jupiter.api.Test + void missingConsumerEvidenceBlocksPromotion() { + var gate = new GrpcEdition2024Gate(); + + org.assertj.core.api.Assertions.assertThat( + gate.promotable(new GrpcEditionCompatibilityReport( + true, false, true))).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-edition-2024:test --tests 'io.backend.skeleton.grpc.advanced.edition.GrpcEdition2024GateTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcEditionCompatibilityReport( + boolean javaCompatible, + boolean consumerLanguagesCompatible, + boolean jsonCompatible) {} + +public final class GrpcEdition2024Gate { + public boolean promotable( + GrpcEditionCompatibilityReport report) { + return report.javaCompatible() + && report.consumerLanguagesCompatible() + && report.jsonCompatible(); + } +} + +public record GrpcEdition2024Policy( + boolean explicitOptIn, + boolean stableDefault) {} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-edition-2024:test --tests 'io.backend.skeleton.grpc.advanced.edition.GrpcEdition2024GateTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-edition-2024/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2024Policy.java' 'modules/grpc-advanced/grpc-edition-2024/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEditionCompatibilityReport.java' 'modules/grpc-advanced/grpc-edition-2024/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2024Gate.java' 'modules/grpc-advanced/grpc-edition-2024/src/main/proto/edition2024/compatibility.proto' 'modules/grpc-advanced/grpc-edition-2024/buf.yaml' 'modules/grpc-advanced/grpc-edition-2024/src/test/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2024GateTest.java' +git commit -m "test: add protobuf edition 2024 lane" +``` + +### Task 3: Protobuf Edition 2026 Watch Lane + +**Files:** +- Create: `modules/grpc-advanced/grpc-edition-2026-experimental/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2026Status.java` +- Create: `modules/grpc-advanced/grpc-edition-2026-experimental/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2026WatchReport.java` +- Create: `modules/grpc-advanced/grpc-edition-2026-experimental/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2026Guard.java` +- Test: `modules/grpc-advanced/grpc-edition-2026-experimental/src/test/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2026GuardTest.java` + +**Interfaces:** +- Consumes: Task 2 Edition 2024 report와 current Protobuf toolchain metadata. +- Produces: released-edition 상태와 toolchain support가 확정되기 전 Stable 사용을 차단하는 Watch lane. + +**Implementation requirements:** +- Edition 2026은 `WATCH` 또는 `EXPERIMENTAL` 상태만 가진다. +- official release status, protoc support, Buf support, Java/runtime support를 분리 기록한다. +- Stable public contract source로 사용하지 않는다. +- CI failure는 watch report를 생성하지만 Stable build와 분리한다. +- 승격은 별도 ADR와 complete cross-language evidence를 요구한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcEdition2026GuardTest { + @org.junit.jupiter.api.Test + void edition2026CannotBeStable() { + var guard = new GrpcEdition2026Guard(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> guard.requireStable( + GrpcEdition2026Status.WATCH)) + .isInstanceOf(IllegalStateException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-edition-2026-experimental:test --tests 'io.backend.skeleton.grpc.advanced.edition.GrpcEdition2026GuardTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcEdition2026Status { + WATCH, EXPERIMENTAL +} + +public final class GrpcEdition2026Guard { + public void requireStable( + GrpcEdition2026Status status) { + throw new IllegalStateException( + "Edition 2026 is not a Stable contract"); + } +} + +public record GrpcEdition2026WatchReport( + boolean officiallyReleased, + boolean protocSupported, + boolean bufSupported, + boolean javaSupported) {} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-edition-2026-experimental:test --tests 'io.backend.skeleton.grpc.advanced.edition.GrpcEdition2026GuardTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-edition-2026-experimental/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2026Status.java' 'modules/grpc-advanced/grpc-edition-2026-experimental/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2026WatchReport.java' 'modules/grpc-advanced/grpc-edition-2026-experimental/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2026Guard.java' 'modules/grpc-advanced/grpc-edition-2026-experimental/src/test/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2026GuardTest.java' +git commit -m "test: track protobuf edition 2026" +``` + +### Task 4: Client Streaming Session·Sequence Contract + +**Files:** +- Create: `modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamSessionId.java` +- Create: `modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamMessage.java` +- Create: `modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamPolicy.java` +- Create: `modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamState.java` +- Test: `modules/grpc-advanced/grpc-client-streaming/src/test/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamPolicyTest.java` + +**Interfaces:** +- Consumes: Stable method policy, deadline, metadata, execution evidence와 generated client-streaming RPC. +- Produces: session ID, client sequence, half-close, server result를 명시하는 client-streaming contract. + +**Implementation requirements:** +- 각 stream message는 session generation과 monotonic client sequence를 가진다. +- half-close 이후 새 message를 거부한다. +- 전체 stream을 transparent retry하지 않는다. +- server final response 전에 일부 message가 적용됐을 수 있음을 evidence로 보존한다. +- stream max duration, idle timeout, message rate와 in-flight limit을 요구한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcClientStreamPolicyTest { + @org.junit.jupiter.api.Test + void duplicateOrDecreasingSequenceIsRejected() { + var policy = new GrpcClientStreamPolicy(); + policy.accept(1); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> policy.accept(1)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-client-streaming:test --tests 'io.backend.skeleton.grpc.advanced.streaming.GrpcClientStreamPolicyTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcClientStreamSessionId(String value) {} + +public record GrpcClientStreamMessage( + GrpcClientStreamSessionId sessionId, + long sequence, + T payload) {} + +public final class GrpcClientStreamPolicy { + private long last; + public void accept(long sequence) { + if (sequence <= last) { + throw new IllegalArgumentException( + "client stream sequence must increase"); + } + last = sequence; + } +} + +public enum GrpcClientStreamState { + OPEN, HALF_CLOSED, COMPLETED, CANCELLED, UNKNOWN +} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-client-streaming:test --tests 'io.backend.skeleton.grpc.advanced.streaming.GrpcClientStreamPolicyTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamSessionId.java' 'modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamMessage.java' 'modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamPolicy.java' 'modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamState.java' 'modules/grpc-advanced/grpc-client-streaming/src/test/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamPolicyTest.java' +git commit -m "feat: define grpc client streaming sessions" +``` + +### Task 5: Client Streaming Dedup·Checkpoint·Resume + +**Files:** +- Create: `modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamCheckpoint.java` +- Create: `modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientMessageDeduplicator.java` +- Create: `modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamResumeDecision.java` +- Test: `modules/grpc-advanced/grpc-client-streaming/src/test/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientMessageDeduplicatorTest.java` + +**Interfaces:** +- Consumes: Task 4 client stream session and application checkpoint storage. +- Produces: 재연결 시 client message 중복을 제거하고 last applied sequence에서 resume하는 contract. + +**Implementation requirements:** +- dedup identity는 stream session + sequence다. +- checkpoint는 application side effect와 가능한 한 같은 transaction에 저장한다. +- checkpoint 이전 duplicate는 replay 결과를 반환하거나 무시한다. +- history·session이 만료되면 새 stream/full resync를 요구한다. +- transport ACK와 application applied checkpoint를 구분한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcClientMessageDeduplicatorTest { + @org.junit.jupiter.api.Test + void alreadyAppliedSequenceIsDuplicate() { + var deduplicator = new GrpcClientMessageDeduplicator(10); + + org.assertj.core.api.Assertions.assertThat( + deduplicator.accept(9)).isFalse(); + org.assertj.core.api.Assertions.assertThat( + deduplicator.accept(11)).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-client-streaming:test --tests 'io.backend.skeleton.grpc.advanced.streaming.GrpcClientMessageDeduplicatorTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcClientStreamCheckpoint( + String sessionId, + long lastAppliedSequence, + String businessRevision) {} + +public final class GrpcClientMessageDeduplicator { + private long lastApplied; + public GrpcClientMessageDeduplicator(long lastApplied) { + this.lastApplied = lastApplied; + } + public boolean accept(long sequence) { + if (sequence <= lastApplied) { + return false; + } + lastApplied = sequence; + return true; + } +} + +public enum GrpcClientStreamResumeDecision { + RESUME, START_NEW, FULL_RESYNC_REQUIRED +} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-client-streaming:test --tests 'io.backend.skeleton.grpc.advanced.streaming.GrpcClientMessageDeduplicatorTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamCheckpoint.java' 'modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientMessageDeduplicator.java' 'modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamResumeDecision.java' 'modules/grpc-advanced/grpc-client-streaming/src/test/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientMessageDeduplicatorTest.java' +git commit -m "feat: add grpc client stream resume" +``` + +### Task 6: Bidirectional Streaming Dual Sequence·Lifecycle + +**Files:** +- Create: `modules/grpc-advanced/grpc-bidi-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiSession.java` +- Create: `modules/grpc-advanced/grpc-bidi-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiDirectionState.java` +- Create: `modules/grpc-advanced/grpc-bidi-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiSequenceTracker.java` +- Create: `modules/grpc-advanced/grpc-bidi-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiResumeState.java` +- Test: `modules/grpc-advanced/grpc-bidi-streaming/src/test/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiSequenceTrackerTest.java` + +**Interfaces:** +- Consumes: Tasks 4–5 client stream contract와 Stable server stream envelope. +- Produces: client→server와 server→client sequence·flow-control·resume를 독립적으로 관리하는 bidi session. + +**Implementation requirements:** +- 양 방향 sequence를 하나의 counter로 합치지 않는다. +- 각 방향의 half-close와 cancellation을 독립적으로 기록한다. +- 양 방향 bounded queue와 single writer를 사용한다. +- resume token은 client applied/server applied sequence를 모두 보존한다. +- session generation mismatch는 full session restart를 요구한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcBidiSequenceTrackerTest { + @org.junit.jupiter.api.Test + void directionsAdvanceIndependently() { + var tracker = new GrpcBidiSequenceTracker(); + tracker.acceptClient(1); + tracker.acceptServer(1); + tracker.acceptServer(2); + + org.assertj.core.api.Assertions.assertThat( + tracker.clientSequence()).isEqualTo(1); + org.assertj.core.api.Assertions.assertThat( + tracker.serverSequence()).isEqualTo(2); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-bidi-streaming:test --tests 'io.backend.skeleton.grpc.advanced.streaming.GrpcBidiSequenceTrackerTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GrpcBidiSequenceTracker { + private long client; + private long server; + public void acceptClient(long sequence) { + if (sequence <= client) { + throw new IllegalArgumentException("client sequence"); + } + client = sequence; + } + public void acceptServer(long sequence) { + if (sequence <= server) { + throw new IllegalArgumentException("server sequence"); + } + server = sequence; + } + public long clientSequence() { return client; } + public long serverSequence() { return server; } +} + +public record GrpcBidiResumeState( + long lastClientApplied, + long lastServerApplied, + long generation) {} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-bidi-streaming:test --tests 'io.backend.skeleton.grpc.advanced.streaming.GrpcBidiSequenceTrackerTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-bidi-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiSession.java' 'modules/grpc-advanced/grpc-bidi-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiDirectionState.java' 'modules/grpc-advanced/grpc-bidi-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiSequenceTracker.java' 'modules/grpc-advanced/grpc-bidi-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiResumeState.java' 'modules/grpc-advanced/grpc-bidi-streaming/src/test/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiSequenceTrackerTest.java' +git commit -m "feat: define grpc bidi stream lifecycle" +``` + +### Task 7: Manual Flow Control 승인 API + +**Files:** +- Create: `modules/grpc-advanced/grpc-manual-flow-control/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcManualFlowControlPolicy.java` +- Create: `modules/grpc-advanced/grpc-manual-flow-control/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcDemandController.java` +- Create: `modules/grpc-advanced/grpc-manual-flow-control/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcDemandDecision.java` +- Test: `modules/grpc-advanced/grpc-manual-flow-control/src/test/java/io/backend/skeleton/grpc/advanced/streaming/GrpcDemandControllerTest.java` + +**Interfaces:** +- Consumes: Stable bounded flow-control policy와 gRPC readiness/request APIs. +- Produces: 승인된 streaming method가 수신 demand와 outbound readiness를 직접 제어하는 GR2 API. + +**Implementation requirements:** +- manual inbound request 수와 outstanding demand를 제한한다. +- application code에 raw observer를 직접 반환하지 않는다. +- read와 write 양쪽이 서로 기다리는 deadlock을 탐지하는 watchdog을 둔다. +- demand와 queue high-watermark를 metric으로 기록한다. +- manual mode가 없는 method에는 자동 flow control을 유지한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcDemandControllerTest { + @org.junit.jupiter.api.Test + void demandCannotExceedConfiguredWindow() { + var controller = new GrpcDemandController(4); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> controller.request(5)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-manual-flow-control:test --tests 'io.backend.skeleton.grpc.advanced.streaming.GrpcDemandControllerTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GrpcDemandController { + private final int maxOutstanding; + private int outstanding; + + public GrpcDemandController(int maxOutstanding) { + this.maxOutstanding = maxOutstanding; + } + + public void request(int count) { + if (count <= 0 + || outstanding + count > maxOutstanding) { + throw new IllegalArgumentException( + "manual flow-control window exceeded"); + } + outstanding += count; + } +} + +public record GrpcManualFlowControlPolicy( + int maxOutstanding, + java.time.Duration deadlockWatchdog) {} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-manual-flow-control:test --tests 'io.backend.skeleton.grpc.advanced.streaming.GrpcDemandControllerTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-manual-flow-control/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcManualFlowControlPolicy.java' 'modules/grpc-advanced/grpc-manual-flow-control/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcDemandController.java' 'modules/grpc-advanced/grpc-manual-flow-control/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcDemandDecision.java' 'modules/grpc-advanced/grpc-manual-flow-control/src/test/java/io/backend/skeleton/grpc/advanced/streaming/GrpcDemandControllerTest.java' +git commit -m "feat: add approved grpc manual flow control" +``` + +### Task 8: Read-only Unary Hedging + +**Files:** +- Create: `modules/grpc-advanced/grpc-hedging/src/main/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingPolicy.java` +- Create: `modules/grpc-advanced/grpc-hedging/src/main/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingEligibility.java` +- Create: `modules/grpc-advanced/grpc-hedging/src/main/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingBudget.java` +- Create: `modules/grpc-advanced/grpc-hedging/src/main/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingResult.java` +- Test: `modules/grpc-advanced/grpc-hedging/src/test/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingEligibilityTest.java` + +**Interfaces:** +- Consumes: Stable method policy, deadline, retry owner와 Service Config. +- Produces: READ_ONLY Unary에만 제한된 duplicate attempt와 first-success 정책. + +**Implementation requirements:** +- READ_ONLY + UNARY만 hedging 대상이다. +- mutation, streaming, idempotency-key write에는 hedging을 금지한다. +- maximum attempts는 초기 2로 제한한다. +- hedging delay와 total deadline·attempt budget을 함께 검증한다. +- duplicate backend load와 cancelled loser result를 metric으로 기록한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcHedgingEligibilityTest { + @org.junit.jupiter.api.Test + void mutationCannotHedge() { + var eligibility = new GrpcHedgingEligibility(); + + org.assertj.core.api.Assertions.assertThat( + eligibility.allowed( + RpcIdempotencyProfile.NON_IDEMPOTENT, + RpcType.UNARY)).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-hedging:test --tests 'io.backend.skeleton.grpc.advanced.resilience.GrpcHedgingEligibilityTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcHedgingPolicy( + int maxAttempts, + java.time.Duration hedgingDelay) { + public GrpcHedgingPolicy { + if (maxAttempts < 2 || maxAttempts > 2) { + throw new IllegalArgumentException( + "initial hedging supports exactly two attempts"); + } + } +} + +public final class GrpcHedgingEligibility { + public boolean allowed( + RpcIdempotencyProfile profile, + RpcType type) { + return profile == RpcIdempotencyProfile.READ_ONLY + && type == RpcType.UNARY; + } +} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-hedging:test --tests 'io.backend.skeleton.grpc.advanced.resilience.GrpcHedgingEligibilityTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-hedging/src/main/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingPolicy.java' 'modules/grpc-advanced/grpc-hedging/src/main/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingEligibility.java' 'modules/grpc-advanced/grpc-hedging/src/main/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingBudget.java' 'modules/grpc-advanced/grpc-hedging/src/main/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingResult.java' 'modules/grpc-advanced/grpc-hedging/src/test/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingEligibilityTest.java' +git commit -m "feat: add read only grpc hedging" +``` + +### Task 9: Custom Name Resolver SPI + +**Files:** +- Create: `modules/grpc-advanced/grpc-custom-resolver/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcEndpointSnapshot.java` +- Create: `modules/grpc-advanced/grpc-custom-resolver/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcResolverUpdate.java` +- Create: `modules/grpc-advanced/grpc-custom-resolver/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcCustomResolver.java` +- Create: `modules/grpc-advanced/grpc-custom-resolver/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcResolverSafetyPolicy.java` +- Test: `modules/grpc-advanced/grpc-custom-resolver/src/test/java/io/backend/skeleton/grpc/advanced/discovery/GrpcResolverSafetyPolicyTest.java` + +**Interfaces:** +- Consumes: Stable Named Channel Profile and custom discovery event source. +- Produces: backend address와 Service Config update를 versioned snapshot으로 전달하는 승인된 resolver SPI. + +**Implementation requirements:** +- resolver update는 monotonic revision과 endpoint set을 가진다. +- 빈 endpoint update, stale revision, invalid authority를 거부한다. +- resolver는 credential·business metadata를 제공하지 않는다. +- Service Config를 함께 제공할 경우 retry owner·LB policy 검증을 통과해야 한다. +- resolver close 후 update를 수신하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcResolverSafetyPolicyTest { + @org.junit.jupiter.api.Test + void staleRevisionIsRejected() { + var policy = new GrpcResolverSafetyPolicy(); + policy.accept(new GrpcEndpointSnapshot(2, + java.util.Set.of("10.0.0.2:9090"))); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> policy.accept(new GrpcEndpointSnapshot( + 1, java.util.Set.of("10.0.0.1:9090")))) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-custom-resolver:test --tests 'io.backend.skeleton.grpc.advanced.discovery.GrpcResolverSafetyPolicyTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcEndpointSnapshot( + long revision, + java.util.Set endpoints) {} + +public final class GrpcResolverSafetyPolicy { + private long revision = -1; + public void accept(GrpcEndpointSnapshot snapshot) { + if (snapshot.endpoints().isEmpty() + || snapshot.revision() <= revision) { + throw new IllegalArgumentException( + "invalid resolver update"); + } + revision = snapshot.revision(); + } +} + +public interface GrpcCustomResolver + extends AutoCloseable { + void start( + java.util.function.Consumer listener); +} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-custom-resolver:test --tests 'io.backend.skeleton.grpc.advanced.discovery.GrpcResolverSafetyPolicyTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-custom-resolver/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcEndpointSnapshot.java' 'modules/grpc-advanced/grpc-custom-resolver/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcResolverUpdate.java' 'modules/grpc-advanced/grpc-custom-resolver/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcCustomResolver.java' 'modules/grpc-advanced/grpc-custom-resolver/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcResolverSafetyPolicy.java' 'modules/grpc-advanced/grpc-custom-resolver/src/test/java/io/backend/skeleton/grpc/advanced/discovery/GrpcResolverSafetyPolicyTest.java' +git commit -m "feat: add grpc custom resolver spi" +``` + +### Task 10: Custom Load Balancer SPI + +**Files:** +- Create: `modules/grpc-advanced/grpc-custom-load-balancer/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcEndpointCandidate.java` +- Create: `modules/grpc-advanced/grpc-custom-load-balancer/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcLoadBalancerPicker.java` +- Create: `modules/grpc-advanced/grpc-custom-load-balancer/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcLoadBalancerDecision.java` +- Create: `modules/grpc-advanced/grpc-custom-load-balancer/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcLoadBalancerSafetyPolicy.java` +- Test: `modules/grpc-advanced/grpc-custom-load-balancer/src/test/java/io/backend/skeleton/grpc/advanced/discovery/GrpcLoadBalancerSafetyPolicyTest.java` + +**Interfaces:** +- Consumes: Task 9 custom resolver snapshot and Stable channel profile. +- Produces: bounded endpoint candidate에서 picker가 결정을 내리고 unsafe policy를 차단하는 GR3 SPI. + +**Implementation requirements:** +- picker는 resolver가 제공한 endpoint만 선택할 수 있다. +- endpoint health, connectivity, weight와 ejection state를 bounded metadata로 사용한다. +- business request body·tenant ID를 routing input으로 사용하지 않는다. +- custom picker failure는 deterministic fallback 또는 call failure로 드러난다. +- load-aware/weighted policy는 performance·fairness·failover evidence를 요구한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcLoadBalancerSafetyPolicyTest { + @org.junit.jupiter.api.Test + void pickerCannotChooseUnknownEndpoint() { + var policy = new GrpcLoadBalancerSafetyPolicy( + java.util.Set.of("a:9090")); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> policy.validate("b:9090")) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-custom-load-balancer:test --tests 'io.backend.skeleton.grpc.advanced.discovery.GrpcLoadBalancerSafetyPolicyTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcEndpointCandidate( + String authority, + int weight, + boolean ready) {} + +public final class GrpcLoadBalancerSafetyPolicy { + private final java.util.Set endpoints; + public GrpcLoadBalancerSafetyPolicy( + java.util.Set endpoints) { + this.endpoints = java.util.Set.copyOf(endpoints); + } + public void validate(String selected) { + if (!endpoints.contains(selected)) { + throw new IllegalArgumentException( + "picker selected unknown endpoint"); + } + } +} + +public interface GrpcLoadBalancerPicker { + GrpcLoadBalancerDecision pick( + java.util.List candidates); +} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-custom-load-balancer:test --tests 'io.backend.skeleton.grpc.advanced.discovery.GrpcLoadBalancerSafetyPolicyTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-custom-load-balancer/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcEndpointCandidate.java' 'modules/grpc-advanced/grpc-custom-load-balancer/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcLoadBalancerPicker.java' 'modules/grpc-advanced/grpc-custom-load-balancer/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcLoadBalancerDecision.java' 'modules/grpc-advanced/grpc-custom-load-balancer/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcLoadBalancerSafetyPolicy.java' 'modules/grpc-advanced/grpc-custom-load-balancer/src/test/java/io/backend/skeleton/grpc/advanced/discovery/GrpcLoadBalancerSafetyPolicyTest.java' +git commit -m "feat: add grpc custom load balancer spi" +``` + +### Task 11: xDS Proxyless Experimental Profile + +**Files:** +- Create: `modules/grpc-advanced/grpc-xds/src/main/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsProfile.java` +- Create: `modules/grpc-advanced/grpc-xds/src/main/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsResourceSnapshot.java` +- Create: `modules/grpc-advanced/grpc-xds/src/main/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsStartupGuard.java` +- Create: `modules/grpc-advanced/grpc-xds/src/main/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsFailurePolicy.java` +- Create: `modules/grpc-advanced/grpc-xds/src/test/resources/xds/bootstrap.json` +- Test: `modules/grpc-advanced/grpc-xds/src/test/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsStartupGuardTest.java` + +**Interfaces:** +- Consumes: Stable channel/security policy, xDS bootstrap and control-plane resources. +- Produces: xds target, resource discovery, traffic policy, mTLS와 fallback을 별도 Experimental profile로 검증. + +**Implementation requirements:** +- `xds:///` target만 explicit xDS profile에서 허용한다. +- bootstrap·control-plane credential·resource namespace를 검증한다. +- application YAML과 xDS에 retry/LB owner를 중복 정의하지 않는다. +- resource not found, stale resource, control-plane outage와 last-known-good policy를 명시한다. +- xDS 기능을 Stable DNS/LB support로 광고하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcXdsStartupGuardTest { + @org.junit.jupiter.api.Test + void xdsRequiresExplicitFeatureFlagAndBootstrap() { + var guard = new GrpcXdsStartupGuard(false); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> guard.validate( + new GrpcXdsProfile( + "xds:///document-service", ""))) + .isInstanceOf(IllegalStateException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-xds:test --tests 'io.backend.skeleton.grpc.advanced.xds.GrpcXdsStartupGuardTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcXdsProfile( + String target, + String bootstrapResource) {} + +public final class GrpcXdsStartupGuard { + private final boolean enabled; + public GrpcXdsStartupGuard(boolean enabled) { + this.enabled = enabled; + } + public void validate(GrpcXdsProfile profile) { + if (!enabled + || !profile.target().startsWith("xds:///") + || profile.bootstrapResource().isBlank()) { + throw new IllegalStateException( + "valid xDS experimental profile is required"); + } + } +} + +public record GrpcXdsResourceSnapshot( + String version, + java.util.Set clusters, + java.time.Instant receivedAt) {} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-xds:test --tests 'io.backend.skeleton.grpc.advanced.xds.GrpcXdsStartupGuardTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-xds/src/main/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsProfile.java' 'modules/grpc-advanced/grpc-xds/src/main/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsResourceSnapshot.java' 'modules/grpc-advanced/grpc-xds/src/main/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsStartupGuard.java' 'modules/grpc-advanced/grpc-xds/src/main/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsFailurePolicy.java' 'modules/grpc-advanced/grpc-xds/src/test/resources/xds/bootstrap.json' 'modules/grpc-advanced/grpc-xds/src/test/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsStartupGuardTest.java' +git commit -m "feat: add experimental grpc xds profile" +``` + +### Task 12: gRPC-Web Unary·Server Streaming Bridge + +**Files:** +- Create: `modules/grpc-advanced/grpc-web/src/main/java/io/backend/skeleton/grpc/advanced/web/GrpcWebRpcSupport.java` +- Create: `modules/grpc-advanced/grpc-web/src/main/java/io/backend/skeleton/grpc/advanced/web/GrpcWebProfile.java` +- Create: `modules/grpc-advanced/grpc-web/src/main/java/io/backend/skeleton/grpc/advanced/web/GrpcWebProxyContract.java` +- Create: `modules/grpc-advanced/grpc-web/src/main/java/io/backend/skeleton/grpc/advanced/web/GrpcWebCompatibilityGate.java` +- Create: `modules/grpc-advanced/grpc-web/src/test/resources/envoy/envoy.yaml` +- Test: `modules/grpc-advanced/grpc-web/src/test/java/io/backend/skeleton/grpc/advanced/web/GrpcWebCompatibilityGateTest.java` + +**Interfaces:** +- Consumes: Stable proto contract, Envoy/proxy profile and browser gRPC-Web client contract. +- Produces: 브라우저에서 Unary와 grpcwebtext Server Streaming만 지원하고 Client/Bidi를 명시적으로 차단하는 bridge. + +**Implementation requirements:** +- 지원 RPC는 Unary와 Server Streaming이다. +- Client Streaming과 Bidirectional Streaming을 지원한다고 선언하지 않는다. +- Envoy 또는 승인된 proxy의 CORS, TLS, metadata/header mapping을 검증한다. +- browser credential·cookie·bearer profile과 CSRF/CORS 정책을 분리한다. +- native gRPC와 gRPC-Web compatibility suite를 같은 schema에 실행한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcWebCompatibilityGateTest { + @org.junit.jupiter.api.Test + void bidiStreamingIsUnsupported() { + var gate = GrpcWebCompatibilityGate.standard(); + + org.assertj.core.api.Assertions.assertThat( + gate.supports(RpcType.BIDI_STREAMING)).isFalse(); + org.assertj.core.api.Assertions.assertThat( + gate.supports(RpcType.UNARY)).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-web:test --tests 'io.backend.skeleton.grpc.advanced.web.GrpcWebCompatibilityGateTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcWebRpcSupport { + UNARY, SERVER_STREAMING +} + +public final class GrpcWebCompatibilityGate { + public static GrpcWebCompatibilityGate standard() { + return new GrpcWebCompatibilityGate(); + } + + public boolean supports(RpcType type) { + return type == RpcType.UNARY + || type == RpcType.SERVER_STREAMING; + } +} + +public record GrpcWebProfile( + String proxy, + boolean tlsRequired, + java.util.Set allowedOrigins) {} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-web:test --tests 'io.backend.skeleton.grpc.advanced.web.GrpcWebCompatibilityGateTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-web/src/main/java/io/backend/skeleton/grpc/advanced/web/GrpcWebRpcSupport.java' 'modules/grpc-advanced/grpc-web/src/main/java/io/backend/skeleton/grpc/advanced/web/GrpcWebProfile.java' 'modules/grpc-advanced/grpc-web/src/main/java/io/backend/skeleton/grpc/advanced/web/GrpcWebProxyContract.java' 'modules/grpc-advanced/grpc-web/src/main/java/io/backend/skeleton/grpc/advanced/web/GrpcWebCompatibilityGate.java' 'modules/grpc-advanced/grpc-web/src/test/resources/envoy/envoy.yaml' 'modules/grpc-advanced/grpc-web/src/test/java/io/backend/skeleton/grpc/advanced/web/GrpcWebCompatibilityGateTest.java' +git commit -m "feat: add grpc web compatibility bridge" +``` + +### Task 13: Servlet HTTP/2 Compatibility Profile + +**Files:** +- Create: `modules/grpc-advanced/grpc-servlet-compat/src/main/java/io/backend/skeleton/grpc/advanced/servlet/GrpcServletCompatibilityProfile.java` +- Create: `modules/grpc-advanced/grpc-servlet-compat/src/main/java/io/backend/skeleton/grpc/advanced/servlet/GrpcServletCapabilityMatrix.java` +- Create: `modules/grpc-advanced/grpc-servlet-compat/src/main/java/io/backend/skeleton/grpc/advanced/servlet/GrpcServletStartupValidator.java` +- Test: `modules/grpc-advanced/grpc-servlet-compat/src/test/java/io/backend/skeleton/grpc/advanced/servlet/GrpcServletStartupValidatorTest.java` + +**Interfaces:** +- Consumes: Stable server profile and Spring Servlet container HTTP/2 integration. +- Produces: 동일 web server/port를 사용하는 Servlet transport의 지원·비지원 기능을 명시하는 compatibility module. + +**Implementation requirements:** +- Servlet transport는 Stable Netty certification을 대체하지 않는다. +- container가 network layer를 소유하므로 native Netty-only 설정을 요청하면 실패한다. +- HTTP/2, TLS, message/metadata limits, health, reflection, drain을 실제 container에서 검증한다. +- keepalive·connection age·flow-control capability 차이를 matrix에 기록한다. +- Servlet profile은 명시적 feature flag와 compatibility release gate를 요구한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcServletStartupValidatorTest { + @org.junit.jupiter.api.Test + void nativeOnlyFlowControlSettingIsRejected() { + var validator = new GrpcServletStartupValidator(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> validator.validate( + new GrpcServletCompatibilityProfile(true))) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-servlet-compat:test --tests 'io.backend.skeleton.grpc.advanced.servlet.GrpcServletStartupValidatorTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcServletCompatibilityProfile( + boolean requestsNativeNettyFlowControl) {} + +public final class GrpcServletStartupValidator { + public void validate( + GrpcServletCompatibilityProfile profile) { + if (profile.requestsNativeNettyFlowControl()) { + throw new IllegalArgumentException( + "Servlet transport cannot promise Netty-only policy"); + } + } +} + +public record GrpcServletCapabilityMatrix( + boolean http2, + boolean tls, + boolean health, + boolean reflection, + boolean nettyFlowControl) {} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-servlet-compat:test --tests 'io.backend.skeleton.grpc.advanced.servlet.GrpcServletStartupValidatorTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-servlet-compat/src/main/java/io/backend/skeleton/grpc/advanced/servlet/GrpcServletCompatibilityProfile.java' 'modules/grpc-advanced/grpc-servlet-compat/src/main/java/io/backend/skeleton/grpc/advanced/servlet/GrpcServletCapabilityMatrix.java' 'modules/grpc-advanced/grpc-servlet-compat/src/main/java/io/backend/skeleton/grpc/advanced/servlet/GrpcServletStartupValidator.java' 'modules/grpc-advanced/grpc-servlet-compat/src/test/java/io/backend/skeleton/grpc/advanced/servlet/GrpcServletStartupValidatorTest.java' +git commit -m "feat: add grpc servlet compatibility profile" +``` + +### Task 14: Spring Integration gRPC Bridge + +**Files:** +- Create: `modules/grpc-advanced/grpc-integration-bridge/src/main/java/io/backend/skeleton/grpc/advanced/integration/GrpcIntegrationInboundGateway.java` +- Create: `modules/grpc-advanced/grpc-integration-bridge/src/main/java/io/backend/skeleton/grpc/advanced/integration/GrpcIntegrationOutboundGateway.java` +- Create: `modules/grpc-advanced/grpc-integration-bridge/src/main/java/io/backend/skeleton/grpc/advanced/integration/GrpcIntegrationBridgePolicy.java` +- Test: `modules/grpc-advanced/grpc-integration-bridge/src/test/java/io/backend/skeleton/grpc/advanced/integration/GrpcIntegrationBridgePolicyTest.java` + +**Interfaces:** +- Consumes: Stable Generated service/stub, Spring Integration Message flow and method policy. +- Produces: Spring `Message` 기반 Integration Flow를 typed RPC 계약과 분리해 연결하는 선택 bridge. + +**Implementation requirements:** +- bridge가 일반 Generated Stub/Service API를 대체한다고 설명하지 않는다. +- message header를 gRPC metadata로 무제한 복사하지 않는다. +- deadline, security, status, error, observability Stable policy를 그대로 적용한다. +- payload type은 registered converter를 통해 Proto message와 변환한다. +- broker-style ACK·durability semantics를 bridge에 추가하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcIntegrationBridgePolicyTest { + @org.junit.jupiter.api.Test + void arbitraryHeadersAreNotForwarded() { + var policy = GrpcIntegrationBridgePolicy.standard(); + + org.assertj.core.api.Assertions.assertThat( + policy.forwardedHeaders()) + .containsExactlyInAnyOrder( + "correlation-id", "traceparent"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-integration-bridge:test --tests 'io.backend.skeleton.grpc.advanced.integration.GrpcIntegrationBridgePolicyTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcIntegrationBridgePolicy( + java.util.Set forwardedHeaders) { + + public static GrpcIntegrationBridgePolicy standard() { + return new GrpcIntegrationBridgePolicy( + java.util.Set.of( + "correlation-id", "traceparent")); + } +} + +public interface GrpcIntegrationInboundGateway {} +public interface GrpcIntegrationOutboundGateway {} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-integration-bridge:test --tests 'io.backend.skeleton.grpc.advanced.integration.GrpcIntegrationBridgePolicyTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-integration-bridge/src/main/java/io/backend/skeleton/grpc/advanced/integration/GrpcIntegrationInboundGateway.java' 'modules/grpc-advanced/grpc-integration-bridge/src/main/java/io/backend/skeleton/grpc/advanced/integration/GrpcIntegrationOutboundGateway.java' 'modules/grpc-advanced/grpc-integration-bridge/src/main/java/io/backend/skeleton/grpc/advanced/integration/GrpcIntegrationBridgePolicy.java' 'modules/grpc-advanced/grpc-integration-bridge/src/test/java/io/backend/skeleton/grpc/advanced/integration/GrpcIntegrationBridgePolicyTest.java' +git commit -m "feat: add spring integration grpc bridge" +``` + +### Task 15: Reactor gRPC Adapter + +**Files:** +- Create: `modules/grpc-advanced/grpc-reactor/src/main/java/io/backend/skeleton/grpc/advanced/reactor/ReactiveGrpcClient.java` +- Create: `modules/grpc-advanced/grpc-reactor/src/main/java/io/backend/skeleton/grpc/advanced/reactor/ReactiveGrpcServerAdapter.java` +- Create: `modules/grpc-advanced/grpc-reactor/src/main/java/io/backend/skeleton/grpc/advanced/reactor/GrpcReactorContextBridge.java` +- Create: `modules/grpc-advanced/grpc-reactor/src/main/java/io/backend/skeleton/grpc/advanced/reactor/GrpcReactorCancellationBridge.java` +- Test: `modules/grpc-advanced/grpc-reactor/src/test/java/io/backend/skeleton/grpc/advanced/reactor/GrpcReactorContextBridgeTest.java` + +**Interfaces:** +- Consumes: Stable typed stub/service adapter, Reactor Context and cancellation contracts. +- Produces: Unary·Server Streaming을 Mono/Flux로 노출하면서 context·deadline·cancel·backpressure를 유지하는 adapter. + +**Implementation requirements:** +- Core public contract를 Reactor에 종속시키지 않는다. +- Mono/Flux cancellation을 gRPC call cancellation으로 전파한다. +- gRPC Context와 Reactor Context의 Actor·Tenant·Trace·Deadline을 bridge한다. +- blocking JPA/SDK 호출을 event loop에서 실행하지 않는다. +- server stream Flux가 Stable bounded flow-control contract를 우회하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcReactorContextBridgeTest { + @org.junit.jupiter.api.Test + void missingActorContextFailsClosed() { + var bridge = new GrpcReactorContextBridge(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> bridge.requireContext(java.util.Map.of())) + .isInstanceOf(IllegalStateException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-reactor:test --tests 'io.backend.skeleton.grpc.advanced.reactor.GrpcReactorContextBridgeTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GrpcReactorContextBridge { + public GrpcRequestContext requireContext( + java.util.Map context) { + var value = context.get("grpcRequestContext"); + if (!(value instanceof GrpcRequestContext requestContext)) { + throw new IllegalStateException( + "gRPC request context is required"); + } + return requestContext; + } +} + +public interface ReactiveGrpcClient {} +public interface ReactiveGrpcServerAdapter {} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-reactor:test --tests 'io.backend.skeleton.grpc.advanced.reactor.GrpcReactorContextBridgeTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-reactor/src/main/java/io/backend/skeleton/grpc/advanced/reactor/ReactiveGrpcClient.java' 'modules/grpc-advanced/grpc-reactor/src/main/java/io/backend/skeleton/grpc/advanced/reactor/ReactiveGrpcServerAdapter.java' 'modules/grpc-advanced/grpc-reactor/src/main/java/io/backend/skeleton/grpc/advanced/reactor/GrpcReactorContextBridge.java' 'modules/grpc-advanced/grpc-reactor/src/main/java/io/backend/skeleton/grpc/advanced/reactor/GrpcReactorCancellationBridge.java' 'modules/grpc-advanced/grpc-reactor/src/test/java/io/backend/skeleton/grpc/advanced/reactor/GrpcReactorContextBridgeTest.java' +git commit -m "feat: add reactor grpc adapter" +``` + +### Task 16: Kotlin Coroutine·Flow Adapter + +**Files:** +- Create: `modules/grpc-advanced/grpc-kotlin/src/main/java/io/backend/skeleton/grpc/advanced/kotlin/GrpcKotlinProfile.java` +- Create: `modules/grpc-advanced/grpc-kotlin/src/main/java/io/backend/skeleton/grpc/advanced/kotlin/GrpcCoroutineContextBridge.java` +- Create: `modules/grpc-advanced/grpc-kotlin/src/main/java/io/backend/skeleton/grpc/advanced/kotlin/GrpcKotlinCompatibilityGate.java` +- Create: `modules/grpc-advanced/grpc-kotlin/src/main/kotlin/io/backend/skeleton/grpc/advanced/kotlin/GrpcCoroutineAdapter.kt` +- Test: `modules/grpc-advanced/grpc-kotlin/src/test/java/io/backend/skeleton/grpc/advanced/kotlin/GrpcKotlinCompatibilityGateTest.java` + +**Interfaces:** +- Consumes: Stable schema artifact, Kotlin generated code and coroutine/Flow runtime. +- Produces: Kotlin coroutine unary와 Flow streaming을 Stable method policy·evidence와 연결하는 optional adapter. + +**Implementation requirements:** +- Java generated contract와 Kotlin generated contract의 schema source를 하나로 유지한다. +- Coroutine cancellation을 gRPC cancellation으로 전파한다. +- Flow backpressure가 Stable stream buffer limit을 우회하지 않는다. +- Kotlin adapter가 Java Core의 evidence·status·deadline types를 보존한다. +- Kotlin toolchain compatibility를 별도 release lane에서 검증한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcKotlinCompatibilityGateTest { + @org.junit.jupiter.api.Test + void schemaDigestMustMatchJavaLane() { + var gate = new GrpcKotlinCompatibilityGate(); + + org.assertj.core.api.Assertions.assertThat( + gate.compatible("sha256:a", "sha256:b")) + .isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-kotlin:test --tests 'io.backend.skeleton.grpc.advanced.kotlin.GrpcKotlinCompatibilityGateTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GrpcKotlinCompatibilityGate { + public boolean compatible( + String javaSchemaDigest, + String kotlinSchemaDigest) { + return javaSchemaDigest.equals(kotlinSchemaDigest); + } +} + +public record GrpcKotlinProfile( + String kotlinVersion, + String grpcKotlinVersion, + boolean coroutineCancellationEnabled) {} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-kotlin:test --tests 'io.backend.skeleton.grpc.advanced.kotlin.GrpcKotlinCompatibilityGateTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-kotlin/src/main/java/io/backend/skeleton/grpc/advanced/kotlin/GrpcKotlinProfile.java' 'modules/grpc-advanced/grpc-kotlin/src/main/java/io/backend/skeleton/grpc/advanced/kotlin/GrpcCoroutineContextBridge.java' 'modules/grpc-advanced/grpc-kotlin/src/main/java/io/backend/skeleton/grpc/advanced/kotlin/GrpcKotlinCompatibilityGate.java' 'modules/grpc-advanced/grpc-kotlin/src/main/kotlin/io/backend/skeleton/grpc/advanced/kotlin/GrpcCoroutineAdapter.kt' 'modules/grpc-advanced/grpc-kotlin/src/test/java/io/backend/skeleton/grpc/advanced/kotlin/GrpcKotlinCompatibilityGateTest.java' +git commit -m "feat: add kotlin grpc adapter" +``` + +### Task 17: Channelz·CSDS 진단과 Advanced Testkit + +**Files:** +- Create: `modules/grpc-advanced/grpc-channel-diagnostics/src/main/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsSnapshot.java` +- Create: `modules/grpc-advanced/grpc-channel-diagnostics/src/main/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsPolicy.java` +- Create: `modules/grpc-advanced/grpc-channel-diagnostics/src/main/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcAdvancedInfrastructureTestkit.java` +- Create: `modules/grpc-advanced/grpc-channel-diagnostics/src/main/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcDiagnosticsRedactor.java` +- Create: `modules/grpc-advanced/grpc-channel-diagnostics/src/test/resources/xds/control-plane-snapshot.json` +- Create: `modules/grpc-advanced/grpc-channel-diagnostics/src/test/resources/grpc-web/envoy.yaml` +- Test: `modules/grpc-advanced/grpc-channel-diagnostics/src/test/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsPolicyTest.java` + +**Interfaces:** +- Consumes: Channel runtime, xDS profile, server state and admin authorization. +- Produces: Channelz/CSDS 기반 channel·subchannel·socket·xDS 상태를 관리자에게 제한적으로 제공하는 진단 계층. + +**Implementation requirements:** +- diagnostics endpoint는 admin network·role을 요구한다. +- socket address·authority는 정책에 따라 마스킹한다. +- token, certificate private material, metadata와 payload를 노출하지 않는다. +- xDS CSDS는 xDS feature가 활성화된 경우에만 등록한다. +- testkit은 gRPC-Web proxy, Servlet container, xDS control-plane failure를 검증한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcChannelDiagnosticsPolicyTest { + @org.junit.jupiter.api.Test + void anonymousAccessIsDenied() { + var policy = GrpcChannelDiagnosticsPolicy.adminOnly(); + + org.assertj.core.api.Assertions.assertThat( + policy.allowed(java.util.Set.of())).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-channel-diagnostics:test --tests 'io.backend.skeleton.grpc.advanced.diagnostics.GrpcChannelDiagnosticsPolicyTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcChannelDiagnosticsPolicy( + java.util.Set requiredRoles) { + public static GrpcChannelDiagnosticsPolicy adminOnly() { + return new GrpcChannelDiagnosticsPolicy( + java.util.Set.of("GRPC_ADMIN")); + } + public boolean allowed(java.util.Set roles) { + return roles.containsAll(requiredRoles); + } +} + +public record GrpcChannelDiagnosticsSnapshot( + String channelProfile, + String connectivityState, + int subchannels, + int sockets, + java.time.Instant capturedAt) {} + +public interface GrpcAdvancedInfrastructureTestkit {} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-channel-diagnostics:test --tests 'io.backend.skeleton.grpc.advanced.diagnostics.GrpcChannelDiagnosticsPolicyTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-channel-diagnostics/src/main/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsSnapshot.java' 'modules/grpc-advanced/grpc-channel-diagnostics/src/main/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsPolicy.java' 'modules/grpc-advanced/grpc-channel-diagnostics/src/main/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcAdvancedInfrastructureTestkit.java' 'modules/grpc-advanced/grpc-channel-diagnostics/src/main/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcDiagnosticsRedactor.java' 'modules/grpc-advanced/grpc-channel-diagnostics/src/test/resources/xds/control-plane-snapshot.json' 'modules/grpc-advanced/grpc-channel-diagnostics/src/test/resources/grpc-web/envoy.yaml' 'modules/grpc-advanced/grpc-channel-diagnostics/src/test/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsPolicyTest.java' +git commit -m "feat: add grpc channel diagnostics and testkit" +``` + +### Task 18: Advanced Capability Promotion Gate + +**Files:** +- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedPromotionEvidence.java` +- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedPromotionDecision.java` +- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedPromotionGate.java` +- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedSupportMatrix.java` +- Create: `docs/compatibility/grpc-advanced-support-matrix.md` +- Create: `docs/runbooks/grpc-advanced-capabilities.md` +- Create: `docs/adr/ADR-065-grpc-advanced-capability-promotion.md` +- Test: `modules/grpc-advanced/grpc-advanced-bootstrap/src/test/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedPromotionGateTest.java` + +**Interfaces:** +- Consumes: Tasks 1–17의 capability-specific compatibility, security, fault, performance, soak evidence. +- Produces: 각 Advanced/Experimental capability를 독립적으로 승격·차단하고 Stable starter 유입을 관리하는 release gate. + +**Implementation requirements:** +- capability별 required evidence를 분리한다. +- Edition, streaming, xDS, gRPC-Web, Servlet, language adapter가 서로의 승격을 묶지 않는다. +- Experimental에서 Advanced Stable로 승격하려면 ADR, runbook, actual environment test와 soak evidence가 필요하다. +- Stable default로 승격하려면 dependency·security·operational cost 재검토가 필요하다. +- 미승격 capability는 feature flag와 별도 module로 유지한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcAdvancedPromotionGateTest { + @org.junit.jupiter.api.Test + void xdsWithoutControlPlaneSoakIsBlocked() { + var evidence = new GrpcAdvancedPromotionEvidence( + GrpcAdvancedCapability.XDS, + java.util.Set.of( + "compatibility", "security", "fault", "adr")); + var gate = new GrpcAdvancedPromotionGate(); + + org.assertj.core.api.Assertions.assertThat( + gate.decide(evidence)) + .isEqualTo(GrpcAdvancedPromotionDecision.BLOCKED); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-advanced-bootstrap:test --tests 'io.backend.skeleton.grpc.advanced.release.GrpcAdvancedPromotionGateTest' +``` + +Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcAdvancedPromotionDecision { + ADVANCED_STABLE, EXPERIMENTAL, BLOCKED +} + +public record GrpcAdvancedPromotionEvidence( + GrpcAdvancedCapability capability, + java.util.Set passed) {} + +public final class GrpcAdvancedPromotionGate { + public GrpcAdvancedPromotionDecision decide( + GrpcAdvancedPromotionEvidence evidence) { + var required = java.util.Set.of( + "compatibility", "security", "fault", + "performance", "soak", "adr", "runbook"); + return evidence.passed().containsAll(required) + ? GrpcAdvancedPromotionDecision.ADVANCED_STABLE + : GrpcAdvancedPromotionDecision.BLOCKED; + } +} +``` + +Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. + +- [ ] **Step 4: Run the focused test and Advanced aggregate suite** + +Run: + +```bash +./gradlew :modules:grpc-advanced:grpc-advanced-bootstrap:test --tests 'io.backend.skeleton.grpc.advanced.release.GrpcAdvancedPromotionGateTest' +./gradlew grpcAdvancedTest +``` + +Expected: PASS for the focused test and Advanced aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedPromotionEvidence.java' 'modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedPromotionDecision.java' 'modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedPromotionGate.java' 'modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedSupportMatrix.java' 'docs/compatibility/grpc-advanced-support-matrix.md' 'docs/runbooks/grpc-advanced-capabilities.md' 'docs/adr/ADR-065-grpc-advanced-capability-promotion.md' 'modules/grpc-advanced/grpc-advanced-bootstrap/src/test/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedPromotionGateTest.java' +git commit -m "docs: complete grpc advanced promotion gate" +``` diff --git a/docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md b/docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md new file mode 100644 index 00000000..e71864a6 --- /dev/null +++ b/docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md @@ -0,0 +1,5174 @@ +# 타입 안전 gRPC 실행 플랫폼 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Spring Boot 4.1을 실행 엔진으로 사용하면서 Protobuf 계약 거버넌스, Named Channel·Server Profile, 세 축 실행 증거, deadline·retry·idempotency, 안정 Status·Rich Error, DNS·Kubernetes discovery, bounded Server Streaming과 실제 Netty 검증을 제공하는 Stable 타입 안전 gRPC 실행 플랫폼을 구축한다. + +**Architecture:** `.proto`와 generated Java contract는 `grpc-proto-contract`·`grpc-codegen`이 소유하고, 일반 애플리케이션은 Generated Stub과 Typed Service Adapter를 통해서만 RPC를 사용한다. `grpc-core-api`와 `grpc-policy`가 method policy·execution evidence·deadline·retry·metadata·streaming 계약을 정의하며, 상태 변경 RPC의 강한 완료 증거는 JPA Operation Ledger와 Application transaction의 결합으로 제공한다. Stable 범위는 Unary와 Server Streaming이며 Client/Bidi Streaming, xDS, gRPC-Web, Hedging과 언어별 adapter는 Advanced 계획으로 격리한다. + +**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Boot 4.1 BOM, Boot-managed Spring gRPC, gRPC Java, Protobuf Java, Protobuf Common Protos, Netty and Netty Shaded transports, Buf CLI/Gradle plugin, Protovalidate, Spring Security, Spring Data JPA, Flyway, Micrometer Observation, OpenTelemetry bridge, JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy. + +## Global Constraints + +- Java runtime은 `21`이다. +- Dependency version의 Source of Truth는 Spring Boot `4.1` BOM이다. +- Spring gRPC, gRPC Java, Protobuf Java, Common Protos 버전을 개별 모듈에서 임의로 override하지 않는다. +- Stable 신규 Schema는 `proto3 + explicit optional`을 사용한다. +- Edition 2024는 Advanced opt-in이고 Edition 2026은 Experimental Watch다. +- Boot-managed Protobuf plugin이 Java code generation의 단일 owner다. +- Buf는 format, lint, breaking, descriptor/image와 schema artifact를 소유한다. +- Stable public API breaking gate는 Buf `FILE` category다. +- 모든 Stable Unary method는 positive deadline profile을 가져야 한다. +- gRPC Status와 업무 Commit evidence를 동일한 상태로 취급하지 않는다. +- 실행 증거는 Transport, Business, Stream 세 축으로 보존한다. +- Response Headers 관측을 Business Commit으로 승격하지 않는다. +- `DEADLINE_EXCEEDED` mutation은 완료 실패가 아니라 Completion Unknown일 수 있다. +- `UNAVAILABLE`만으로 상태 변경 RPC를 재호출하지 않는다. +- explicit retry owner는 Application, gRPC Platform, Service Mesh 중 하나다. +- non-idempotent method에 explicit retry와 hedging을 허용하지 않는다. +- 상태 변경 RPC에 exactly-once transport 보장을 선언하지 않는다. +- `IDEMPOTENCY_KEY_REQUIRED` method는 server-side Operation Ledger와 request fingerprint를 사용한다. +- Operation Ledger와 business mutation은 가능한 경우 동일 DB transaction에 commit한다. +- Stable RPC 유형은 Unary와 Server Streaming이다. +- Client Streaming과 Bidirectional Streaming은 Advanced 계획에서 구현한다. +- Server Streaming writer는 bounded queue와 single serialized writer를 사용한다. +- StreamObserver에 여러 thread가 직접 `onNext()`하지 않는다. +- partial stream delivery 이후 whole-call transparent retry를 수행하지 않는다. +- GraphQL/WebSocket/Messaging/Fileserver/Object Storage의 책임을 gRPC에서 중복 구현하지 않는다. +- 대형 binary는 Fileserver/Object Storage reference로 전달한다. +- 일반 Application source에서 raw gRPC builder·call API import를 금지한다. +- Netty가 Stable certification transport다. +- In-process transport는 fast contract test용이며 network/TLS evidence가 아니다. +- dev·stage·prod는 TLS를 요구하고 trust-all·hostname verification off를 금지한다. +- production Reflection은 기본 비활성이다. +- Stable resolver는 Static·DNS, Stable LB는 pick_first·round_robin이다. +- xDS와 custom resolver/LB는 Advanced다. +- Metric tag에 raw metadata, payload, actor/user/tenant/object/stream/idempotency ID를 넣지 않는다. +- Stable module root는 `modules/grpc`이다. +- Root package는 `io.backend.skeleton.grpc`이다. +- 모든 task는 red-green TDD와 독립 commit으로 끝난다. +- 실제 저장소 구조가 예상 경로와 다르면 파일 경로만 매핑하고 공개 계약·불변 조건·테스트 의미는 변경하지 않는다. + +--- + +## Execution Baseline + +```text +Stable Task 1–53 +→ Stable Release Gate +→ Advanced Task 1–18 +``` + +## Stable Module Map + +```text +modules/grpc/ +├── grpc-core-api +├── grpc-proto-contract +├── grpc-codegen +├── grpc-client +├── grpc-server +├── grpc-policy +├── grpc-discovery +├── grpc-admin +├── grpc-observability +├── grpc-operation-ledger-jpa +├── grpc-spring-boot-starter +├── grpc-testkit-core +├── grpc-testkit-inprocess +├── grpc-testkit-netty +└── grpc-testkit-fault +``` + +## File Ownership Rules + +```text +grpc-core-api +→ identifiers, method policy, evidence, failure, request context, deadline primitive + +grpc-proto-contract +→ public/common .proto and schema source + +grpc-codegen +→ Gradle convention, Buf governance, descriptor and consumer fixture + +grpc-client +→ named channel, runtime generation, typed stub, client metadata + +grpc-server +→ service adapter, interceptor order, Netty profiles, architecture boundary + +grpc-policy +→ validation, deadline, cancellation, security, status, retry, + idempotency, streaming, size and compression + +grpc-discovery +→ stable resolver, load-balancing and Kubernetes profile + +grpc-admin +→ health, reflection, drain and safe runtime snapshot + +grpc-observability +→ bounded metric/trace/logging convention + +grpc-operation-ledger-jpa +→ durable mutation idempotency and completion evidence + +grpc-spring-boot-starter +→ auto-configuration, properties and startup validation + +grpc-testkit-* +→ in-process, real Netty, fault, reliability and performance certification +``` + +## Delivery Phases + +| Phase | Tasks | Independently testable result | +|---|---:|---| +| Foundation | 1–7 | Stable modules, identifiers, policy, evidence, failure, deadline, context | +| Contract Governance | 8–11 | Proto rules, Buf, Boot codegen, descriptor/consumer gate | +| Server Boundary | 12–23 | Validation, architecture, adapter, interceptor, server, security, health, reflection, drain | +| Client Runtime | 24–30 | Named Channel, generation, typed stub, metadata, deadline, cancellation, retry owner | +| Reliability | 31–33 | Retry eligibility, operation ledger, idempotency | +| Recovery·Discovery | 34–36 | Completion query, DNS/LB, Kubernetes profiles | +| Server Streaming | 37–43 | Envelope, writer, flow control, resume, lifetime, wait-for-ready, payload | +| Operations·Verification | 44–53 | Observability, admin, in-process, Netty, fault, reliability, performance, starter, release gate | + +--- +### Task 1: Gradle 멀티모듈과 Stable 테스트 집계 + +**Files:** +- Modify: `settings.gradle.kts` +- Modify: `build.gradle.kts` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcStableModuleCatalog.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcStableBuildInvariant.java` +- Create: `modules/grpc/build.gradle.kts` +- Create: `modules/grpc/grpc-core-api/build.gradle.kts` +- Create: `modules/grpc/grpc-proto-contract/build.gradle.kts` +- Create: `modules/grpc/grpc-codegen/build.gradle.kts` +- Create: `modules/grpc/grpc-client/build.gradle.kts` +- Create: `modules/grpc/grpc-server/build.gradle.kts` +- Create: `modules/grpc/grpc-policy/build.gradle.kts` +- Create: `modules/grpc/grpc-discovery/build.gradle.kts` +- Create: `modules/grpc/grpc-admin/build.gradle.kts` +- Create: `modules/grpc/grpc-observability/build.gradle.kts` +- Create: `modules/grpc/grpc-operation-ledger-jpa/build.gradle.kts` +- Create: `modules/grpc/grpc-spring-boot-starter/build.gradle.kts` +- Create: `modules/grpc/grpc-testkit-core/build.gradle.kts` +- Create: `modules/grpc/grpc-testkit-inprocess/build.gradle.kts` +- Create: `modules/grpc/grpc-testkit-netty/build.gradle.kts` +- Create: `modules/grpc/grpc-testkit-fault/build.gradle.kts` +- Test: `modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/core/GrpcStableModuleCatalogTest.java` + +**Interfaces:** +- Consumes: Root Gradle settings, version catalog와 Spring Boot 4.1 BOM. +- Produces: `modules/grpc`의 Stable module 목록, 금지 dependency 규칙, `grpcStableTest` aggregate task. + +**Implementation requirements:** +- Stable module은 `grpc-core-api`, `grpc-proto-contract`, `grpc-codegen`, `grpc-client`, `grpc-server`, `grpc-policy`, `grpc-discovery`, `grpc-admin`, `grpc-observability`, `grpc-operation-ledger-jpa`, `grpc-spring-boot-starter`, `grpc-testkit-core`, `grpc-testkit-inprocess`, `grpc-testkit-netty`, `grpc-testkit-fault`로 고정한다. +- 모든 runtime dependency version은 Spring Boot 4.1 BOM을 사용한다. +- `grpc-proto-contract`와 `grpc-core-api`는 Spring Boot·Netty runtime에 의존하지 않는다. +- Stable starter가 `modules/grpc-advanced`를 참조하면 build를 실패시킨다. +- `grpcStableTest`, `grpcContractTest`, `grpcNettyTest`, `grpcFaultTest`, `grpcPerformanceTest` task를 등록한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcStableModuleCatalogTest { + @org.junit.jupiter.api.Test + void stableModuleSetIsExactAndAdvancedIsExcluded() { + var catalog = GrpcStableModuleCatalog.standard(); + + org.assertj.core.api.Assertions.assertThat(catalog.modules()) + .contains("grpc-core-api", "grpc-client", "grpc-server") + .doesNotContain("grpc-xds", "grpc-web"); + org.assertj.core.api.Assertions.assertThat( + GrpcStableBuildInvariant.advancedDependencyAllowed()) + .isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.core.GrpcStableModuleCatalogTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcStableModuleCatalog( + java.util.Set modules) { + + public static GrpcStableModuleCatalog standard() { + return new GrpcStableModuleCatalog(java.util.Set.of( + "grpc-core-api", "grpc-proto-contract", "grpc-codegen", + "grpc-client", "grpc-server", "grpc-policy", + "grpc-discovery", "grpc-admin", "grpc-observability", + "grpc-operation-ledger-jpa", "grpc-spring-boot-starter", + "grpc-testkit-core", "grpc-testkit-inprocess", + "grpc-testkit-netty", "grpc-testkit-fault")); + } +} + +public final class GrpcStableBuildInvariant { + public static boolean advancedDependencyAllowed() { + return false; + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.core.GrpcStableModuleCatalogTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcStableModuleCatalog.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcStableBuildInvariant.java' 'modules/grpc/build.gradle.kts' 'modules/grpc/grpc-core-api/build.gradle.kts' 'modules/grpc/grpc-proto-contract/build.gradle.kts' 'modules/grpc/grpc-codegen/build.gradle.kts' 'modules/grpc/grpc-client/build.gradle.kts' 'modules/grpc/grpc-server/build.gradle.kts' 'modules/grpc/grpc-policy/build.gradle.kts' 'modules/grpc/grpc-discovery/build.gradle.kts' 'modules/grpc/grpc-admin/build.gradle.kts' 'modules/grpc/grpc-observability/build.gradle.kts' 'modules/grpc/grpc-operation-ledger-jpa/build.gradle.kts' 'modules/grpc/grpc-spring-boot-starter/build.gradle.kts' 'modules/grpc/grpc-testkit-core/build.gradle.kts' 'modules/grpc/grpc-testkit-inprocess/build.gradle.kts' 'modules/grpc/grpc-testkit-netty/build.gradle.kts' 'modules/grpc/grpc-testkit-fault/build.gradle.kts' 'modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/core/GrpcStableModuleCatalogTest.java' 'settings.gradle.kts' 'build.gradle.kts' +git commit -m "build: establish grpc stable module graph" +``` + +### Task 2: Core 식별자와 RPC 유형 + +**Files:** +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcMethodName.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcServiceName.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcChannelProfileName.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/RpcType.java` +- Test: `modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/core/GrpcCoreIdentifiersTest.java` + +**Interfaces:** +- Consumes: Task 1의 Stable module graph. +- Produces: 공백·불완전 이름을 거부하는 bounded identifier와 Unary·Streaming RPC 유형. + +**Implementation requirements:** +- `GrpcMethodName`은 canonical full method name인 `package.Service/Method` 형식을 요구한다. +- `GrpcServiceName`과 `GrpcChannelProfileName`은 빈 값과 제어 문자를 거부한다. +- `RpcType`은 `UNARY`, `SERVER_STREAMING`, `CLIENT_STREAMING`, `BIDI_STREAMING`만 가진다. +- 식별자 원문은 metric label에 자동 사용하지 않는다. +- generated descriptor와 catalog가 동일 canonical name을 사용한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcCoreIdentifiersTest { + @org.junit.jupiter.api.Test + void methodNameRequiresServiceAndMethod() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GrpcMethodName("DocumentService")) + .isInstanceOf(IllegalArgumentException.class); + + org.assertj.core.api.Assertions.assertThat( + new GrpcMethodName( + "hyeonworks.document.v1.DocumentService/GetDocument") + .value()) + .endsWith("/GetDocument"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.core.GrpcCoreIdentifiersTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcMethodName(String value) { + public GrpcMethodName { + if (value == null + || !value.matches("[A-Za-z0-9_.]+/[A-Za-z0-9_]+")) { + throw new IllegalArgumentException( + "full gRPC method name is required"); + } + } +} + +public record GrpcServiceName(String value) { + public GrpcServiceName { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("service name"); + } + } +} + +public enum RpcType { + UNARY, SERVER_STREAMING, CLIENT_STREAMING, BIDI_STREAMING +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.core.GrpcCoreIdentifiersTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcMethodName.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcServiceName.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcChannelProfileName.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/RpcType.java' 'modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/core/GrpcCoreIdentifiersTest.java' +git commit -m "feat: add grpc core identifiers" +``` + +### Task 3: Method Policy와 Idempotency Profile + +**Files:** +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/policy/RpcIdempotencyProfile.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/policy/GrpcMethodPolicy.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/policy/GrpcMethodPolicyCatalog.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/policy/WaitForReadyPolicy.java` +- Test: `modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/policy/GrpcMethodPolicyCatalogTest.java` + +**Interfaces:** +- Consumes: Task 2의 canonical method name과 RPC 유형. +- Produces: method별 deadline·retry·idempotency·size·metadata 정책을 조회하는 immutable catalog. + +**Implementation requirements:** +- Idempotency profile은 `READ_ONLY`, `NATURALLY_IDEMPOTENT`, `IDEMPOTENCY_KEY_REQUIRED`, `CONDITIONALLY_IDEMPOTENT`, `NON_IDEMPOTENT`, `STREAMING`이다. +- Stable Unary method에는 deadline profile이 반드시 존재한다. +- `NON_IDEMPOTENT` method에 explicit retry profile을 연결하면 catalog build를 실패시킨다. +- `STREAMING` profile은 whole-call retry profile을 허용하지 않는다. +- 중복 method registration과 descriptor에 없는 method registration을 거부한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcMethodPolicyCatalogTest { + @org.junit.jupiter.api.Test + void nonIdempotentMethodCannotEnableExplicitRetry() { + var policy = GrpcMethodPolicy.nonIdempotentUnary( + new GrpcMethodName("x.y.CommandService/Create"), + "user-sync", + "retry-write"); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GrpcMethodPolicyCatalog.of(java.util.List.of(policy))) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.policy.GrpcMethodPolicyCatalogTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum RpcIdempotencyProfile { + READ_ONLY, + NATURALLY_IDEMPOTENT, + IDEMPOTENCY_KEY_REQUIRED, + CONDITIONALLY_IDEMPOTENT, + NON_IDEMPOTENT, + STREAMING +} + +public record GrpcMethodPolicy( + GrpcMethodName method, + RpcType rpcType, + RpcIdempotencyProfile idempotency, + String deadlineProfile, + String retryProfile, + WaitForReadyPolicy waitForReady) { + + public static GrpcMethodPolicy nonIdempotentUnary( + GrpcMethodName method, + String deadline, + String retry) { + return new GrpcMethodPolicy(method, RpcType.UNARY, + RpcIdempotencyProfile.NON_IDEMPOTENT, + deadline, retry, WaitForReadyPolicy.DISABLED); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.policy.GrpcMethodPolicyCatalogTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/policy/RpcIdempotencyProfile.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/policy/GrpcMethodPolicy.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/policy/GrpcMethodPolicyCatalog.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/policy/WaitForReadyPolicy.java' 'modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/policy/GrpcMethodPolicyCatalogTest.java' +git commit -m "feat: define grpc method policy catalog" +``` + +### Task 4: 세 축 실행 증거 모델 + +**Files:** +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/evidence/GrpcTransportEvidence.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/evidence/GrpcBusinessEvidence.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/evidence/GrpcStreamEvidence.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/evidence/GrpcExecutionEvidence.java` +- Test: `modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/evidence/GrpcExecutionEvidenceTest.java` + +**Interfaces:** +- Consumes: Task 2의 RPC 유형과 method identity. +- Produces: Transport·Business·Stream evidence를 독립적으로 보존하는 immutable snapshot. + +**Implementation requirements:** +- `RESPONSE_HEADERS_SEEN`을 `COMMIT_CONFIRMED`로 자동 승격하지 않는다. +- Stream evidence는 `None`, `Partial`, `Applied`, `Resumable` sealed hierarchy다. +- `APPLIED`는 application acknowledgement 증거가 있을 때만 생성한다. +- Unary RPC에서 non-none stream evidence를 설정하면 검증 오류다. +- Evidence snapshot은 예외와 관측 event에서 동일 타입을 사용한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcExecutionEvidenceTest { + @org.junit.jupiter.api.Test + void responseHeadersDoNotProveBusinessCommit() { + var evidence = new GrpcExecutionEvidence( + GrpcTransportEvidence.RESPONSE_HEADERS_SEEN, + GrpcBusinessEvidence.UNKNOWN, + new GrpcStreamEvidence.None()); + + org.assertj.core.api.Assertions.assertThat(evidence.business()) + .isEqualTo(GrpcBusinessEvidence.UNKNOWN); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.evidence.GrpcExecutionEvidenceTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcTransportEvidence { + NOT_SENT, + MAY_HAVE_LEFT_CLIENT, + RESPONSE_HEADERS_SEEN, + RESPONSE_MESSAGE_SEEN, + TRAILERS_SEEN +} + +public enum GrpcBusinessEvidence { + NOT_OBSERVED, + APPLICATION_STARTED, + COMMIT_CONFIRMED, + ABORT_CONFIRMED, + UNKNOWN +} + +public sealed interface GrpcStreamEvidence { + record None() implements GrpcStreamEvidence {} + record Partial(long lastSequence) implements GrpcStreamEvidence {} + record Applied(long lastAppliedSequence) + implements GrpcStreamEvidence {} + record Resumable(String snapshotVersion, String cursor) + implements GrpcStreamEvidence {} +} + +public record GrpcExecutionEvidence( + GrpcTransportEvidence transport, + GrpcBusinessEvidence business, + GrpcStreamEvidence stream) {} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.evidence.GrpcExecutionEvidenceTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/evidence/GrpcTransportEvidence.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/evidence/GrpcBusinessEvidence.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/evidence/GrpcStreamEvidence.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/evidence/GrpcExecutionEvidence.java' 'modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/evidence/GrpcExecutionEvidenceTest.java' +git commit -m "feat: model grpc execution evidence" +``` + +### Task 5: Stable 오류·Status·Completion Outcome + +**Files:** +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/error/GrpcFailureCategory.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/error/GrpcCompletionOutcome.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/error/GrpcFailureContext.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/error/GrpcPlatformException.java` +- Test: `modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/error/GrpcFailureContextTest.java` + +**Interfaces:** +- Consumes: Task 4의 실행 증거. +- Produces: gRPC Status와 업무 완료 불명확성을 분리하는 안정 오류 모델. + +**Implementation requirements:** +- `GrpcCompletionOutcome`은 `COMPLETED`, `REJECTED`, `COMPLETION_UNKNOWN`, `PARTIAL_STREAM`을 표현한다. +- failure context에 method, status code, evidence, retry disposition, attempt, elapsed, trace ID를 보존한다. +- payload, raw metadata, token, idempotency key 원문을 exception message에 넣지 않는다. +- `DEADLINE_EXCEEDED` mutation은 기본적으로 `COMPLETION_UNKNOWN` 후보다. +- provider·driver exception은 cause로 보존하되 공개 wire detail로 자동 변환하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcFailureContextTest { + @org.junit.jupiter.api.Test + void deadlineAfterPossibleSendIsCompletionUnknown() { + var context = GrpcFailureContext.deadlineExceeded( + new GrpcMethodName("x.y.CommandService/Create"), + GrpcTransportEvidence.MAY_HAVE_LEFT_CLIENT); + + org.assertj.core.api.Assertions.assertThat(context.outcome()) + .isEqualTo(GrpcCompletionOutcome.COMPLETION_UNKNOWN); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.error.GrpcFailureContextTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcCompletionOutcome { + COMPLETED, REJECTED, COMPLETION_UNKNOWN, PARTIAL_STREAM +} + +public record GrpcFailureContext( + GrpcMethodName method, + String statusCode, + GrpcExecutionEvidence evidence, + GrpcCompletionOutcome outcome, + boolean retryable, + int attempt, + java.time.Duration elapsed, + String traceId) { + + public static GrpcFailureContext deadlineExceeded( + GrpcMethodName method, + GrpcTransportEvidence transport) { + return new GrpcFailureContext(method, "DEADLINE_EXCEEDED", + new GrpcExecutionEvidence(transport, + GrpcBusinessEvidence.UNKNOWN, + new GrpcStreamEvidence.None()), + GrpcCompletionOutcome.COMPLETION_UNKNOWN, + false, 1, java.time.Duration.ZERO, "redacted"); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.error.GrpcFailureContextTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/error/GrpcFailureCategory.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/error/GrpcCompletionOutcome.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/error/GrpcFailureContext.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/error/GrpcPlatformException.java' 'modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/error/GrpcFailureContextTest.java' +git commit -m "feat: add grpc failure and completion model" +``` + +### Task 6: Deadline·Cancellation Core Primitive + +**Files:** +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineBudget.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/deadline/GrpcCancellationToken.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineProfile.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineExceededException.java` +- Test: `modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineBudgetTest.java` + +**Interfaces:** +- Consumes: Task 3의 method policy. +- Produces: 상위 deadline과 method profile에서 안전 margin을 차감한 downstream budget. + +**Implementation requirements:** +- 모든 Stable Unary method에 positive deadline을 요구한다. +- downstream deadline은 parent remaining과 method default 중 더 짧은 값을 사용한다. +- serialization/trailer safety reserve를 차감한다. +- 남은 시간이 minimum attempt budget보다 작으면 새 dependency call을 시작하지 않는다. +- cancellation token은 cancel reason과 발생 시각을 보존하고 idempotent하게 취소된다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcDeadlineBudgetTest { + @org.junit.jupiter.api.Test + void downstreamBudgetNeverExceedsParent() { + var parent = GrpcDeadlineBudget.of( + java.time.Duration.ofSeconds(2)); + var downstream = parent.child( + java.time.Duration.ofSeconds(5), + java.time.Duration.ofMillis(100)); + + org.assertj.core.api.Assertions.assertThat( + downstream.remaining()) + .isLessThanOrEqualTo(java.time.Duration.ofMillis(1900)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.deadline.GrpcDeadlineBudgetTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcDeadlineBudget( + java.time.Instant deadline) { + + public static GrpcDeadlineBudget of( + java.time.Duration duration) { + if (duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException("positive deadline"); + } + return new GrpcDeadlineBudget( + java.time.Instant.now().plus(duration)); + } + + public java.time.Duration remaining() { + var value = java.time.Duration.between( + java.time.Instant.now(), deadline); + return value.isNegative() ? java.time.Duration.ZERO : value; + } + + public GrpcDeadlineBudget child( + java.time.Duration configured, + java.time.Duration reserve) { + var child = java.time.Instant.now() + .plus(configured).minus(reserve); + return new GrpcDeadlineBudget( + child.isBefore(deadline) ? child : deadline.minus(reserve)); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.deadline.GrpcDeadlineBudgetTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineBudget.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/deadline/GrpcCancellationToken.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineProfile.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineExceededException.java' 'modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineBudgetTest.java' +git commit -m "feat: add grpc deadline and cancellation primitives" +``` + +### Task 7: Metadata·Actor·Tenant Request Context + +**Files:** +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/context/GrpcRequestContext.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/context/GrpcMetadataKey.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/context/GrpcMetadataBudget.java` +- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/context/GrpcClientIdentity.java` +- Test: `modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/context/GrpcMetadataBudgetTest.java` + +**Interfaces:** +- Consumes: Task 6의 deadline budget과 기존 Security의 Actor·Tenant context. +- Produces: 허용된 metadata만 immutable request context로 변환하는 bounded contract. + +**Implementation requirements:** +- metadata key는 allowlist catalog에 등록된 ASCII/binary key만 허용한다. +- `grpc-` prefix 사용자 정의를 거부한다. +- 전체 hard budget과 user-defined soft budget을 분리한다. +- Actor·Tenant는 검증된 authentication source에서만 생성하고 raw tenant header를 직접 신뢰하지 않는다. +- credential, token, PII와 raw metadata map은 public request context에서 제거한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcMetadataBudgetTest { + @org.junit.jupiter.api.Test + void oversizedUserMetadataIsRejected() { + var budget = new GrpcMetadataBudget(8192, 4096); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> budget.validate(2000, 5000)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.context.GrpcMetadataBudgetTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcMetadataBudget( + int hardBytes, + int userBytes) { + + public void validate(int frameworkBytes, int suppliedUserBytes) { + if (suppliedUserBytes > userBytes + || frameworkBytes + suppliedUserBytes > hardBytes) { + throw new IllegalArgumentException( + "gRPC metadata budget exceeded"); + } + } +} + +public record GrpcRequestContext( + Object actor, + Object tenant, + java.util.Locale locale, + String correlationId, + String traceId, + GrpcDeadlineBudget deadline, + GrpcClientIdentity client) {} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.context.GrpcMetadataBudgetTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/context/GrpcRequestContext.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/context/GrpcMetadataKey.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/context/GrpcMetadataBudget.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/context/GrpcClientIdentity.java' 'modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/context/GrpcMetadataBudgetTest.java' +git commit -m "feat: define grpc metadata and request context" +``` + +### Task 8: Proto Style Manifest와 Schema 규칙 + +**Files:** +- Create: `modules/grpc/grpc-proto-contract/src/main/java/io/backend/skeleton/grpc/contract/GrpcProtoStyleManifest.java` +- Create: `modules/grpc/grpc-proto-contract/src/main/java/io/backend/skeleton/grpc/contract/GrpcProtoRuleViolation.java` +- Create: `modules/grpc/grpc-proto-contract/src/main/java/io/backend/skeleton/grpc/contract/GrpcProtoContractValidator.java` +- Create: `modules/grpc/grpc-proto-contract/src/main/proto/hyeonworks/grpc/common/v1/error.proto` +- Create: `modules/grpc/grpc-proto-contract/src/main/proto/hyeonworks/grpc/common/v1/stream.proto` +- Create: `modules/grpc/grpc-proto-contract/src/main/proto/buf.yaml` +- Test: `modules/grpc/grpc-proto-contract/src/test/java/io/backend/skeleton/grpc/contract/GrpcProtoContractValidatorTest.java` + +**Interfaces:** +- Consumes: Task 2의 service·method identity와 source `.proto` files. +- Produces: proto3+optional, package, reserved, enum zero, WKT 사용 규칙을 검증하는 contract validator. + +**Implementation requirements:** +- Stable source는 `proto3`이며 presence가 필요한 scalar는 `optional`이다. +- package는 `{organization}.{domain}.v{major}` 규칙을 따른다. +- `java_multiple_files = true`와 hand-written package와 분리된 `java_package`를 요구한다. +- 삭제된 field number/name의 `reserved` 선언을 schema history와 비교한다. +- enum 0 값은 `_UNSPECIFIED` suffix를 요구하고 `Any`, `Struct`, map 사용을 allowlist한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcProtoContractValidatorTest { + @org.junit.jupiter.api.Test + void enumWithoutUnspecifiedZeroIsRejected() { + var manifest = GrpcProtoStyleManifest.stable(); + var validator = new GrpcProtoContractValidator(manifest); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> validator.validateEnumZero("Status", "ACTIVE", 0)) + .isInstanceOf(GrpcProtoRuleViolation.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-proto-contract:test --tests 'io.backend.skeleton.grpc.contract.GrpcProtoContractValidatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcProtoStyleManifest( + String syntax, + boolean explicitPresence, + boolean javaMultipleFiles, + String enumZeroSuffix) { + + public static GrpcProtoStyleManifest stable() { + return new GrpcProtoStyleManifest( + "proto3", true, true, "_UNSPECIFIED"); + } +} + +public final class GrpcProtoContractValidator { + private final GrpcProtoStyleManifest manifest; + + public GrpcProtoContractValidator( + GrpcProtoStyleManifest manifest) { + this.manifest = manifest; + } + + public void validateEnumZero( + String enumName, String valueName, int number) { + if (number == 0 + && !valueName.endsWith( + manifest.enumZeroSuffix())) { + throw new GrpcProtoRuleViolation(enumName); + } + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-proto-contract:test --tests 'io.backend.skeleton.grpc.contract.GrpcProtoContractValidatorTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-proto-contract/src/main/java/io/backend/skeleton/grpc/contract/GrpcProtoStyleManifest.java' 'modules/grpc/grpc-proto-contract/src/main/java/io/backend/skeleton/grpc/contract/GrpcProtoRuleViolation.java' 'modules/grpc/grpc-proto-contract/src/main/java/io/backend/skeleton/grpc/contract/GrpcProtoContractValidator.java' 'modules/grpc/grpc-proto-contract/src/main/proto/hyeonworks/grpc/common/v1/error.proto' 'modules/grpc/grpc-proto-contract/src/main/proto/hyeonworks/grpc/common/v1/stream.proto' 'modules/grpc/grpc-proto-contract/src/main/proto/buf.yaml' 'modules/grpc/grpc-proto-contract/src/test/java/io/backend/skeleton/grpc/contract/GrpcProtoContractValidatorTest.java' +git commit -m "feat: enforce grpc proto style manifest" +``` + +### Task 9: Buf Format·Lint·Breaking Governance + +**Files:** +- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcBufPolicy.java` +- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcBreakingCategory.java` +- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcSchemaBaseline.java` +- Create: `modules/grpc/grpc-codegen/src/main/kotlin/io.backend.grpc-buf-conventions.gradle.kts` +- Create: `modules/grpc/grpc-proto-contract/buf.lock` +- Create: `modules/grpc/grpc-proto-contract/buf.gen.yaml` +- Test: `modules/grpc/grpc-codegen/src/test/java/io/backend/skeleton/grpc/codegen/GrpcBufPolicyTest.java` + +**Interfaces:** +- Consumes: Task 8의 Proto style manifest와 released schema artifact. +- Produces: Buf `FILE` breaking gate, lint·format·descriptor build를 Gradle lifecycle에 연결하는 convention. + +**Implementation requirements:** +- `bufFormatCheck`, `bufLint`, `bufBuild`, `bufBreaking`을 CI task로 등록한다. +- Stable public API는 Buf `FILE` category를 사용한다. +- 비교 baseline은 마지막 released schema artifact로 고정한다. +- wire-only compatibility로 source-breaking 변경을 통과시키지 않는다. +- breaking 결과와 schema hash를 release artifact에 보존한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcBufPolicyTest { + @org.junit.jupiter.api.Test + void stableUsesFileBreakingCategory() { + var policy = GrpcBufPolicy.stable( + new GrpcSchemaBaseline("1.0.0", "sha256:abc")); + + org.assertj.core.api.Assertions.assertThat(policy.category()) + .isEqualTo(GrpcBreakingCategory.FILE); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-codegen:test --tests 'io.backend.skeleton.grpc.codegen.GrpcBufPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcBreakingCategory { + FILE, PACKAGE, WIRE_JSON, WIRE +} + +public record GrpcSchemaBaseline( + String version, + String digest) {} + +public record GrpcBufPolicy( + GrpcBreakingCategory category, + GrpcSchemaBaseline baseline, + boolean formatRequired, + boolean lintRequired) { + + public static GrpcBufPolicy stable( + GrpcSchemaBaseline baseline) { + return new GrpcBufPolicy( + GrpcBreakingCategory.FILE, + baseline, true, true); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-codegen:test --tests 'io.backend.skeleton.grpc.codegen.GrpcBufPolicyTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcBufPolicy.java' 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcBreakingCategory.java' 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcSchemaBaseline.java' 'modules/grpc/grpc-codegen/src/main/kotlin/io.backend.grpc-buf-conventions.gradle.kts' 'modules/grpc/grpc-proto-contract/buf.lock' 'modules/grpc/grpc-proto-contract/buf.gen.yaml' 'modules/grpc/grpc-codegen/src/test/java/io/backend/skeleton/grpc/codegen/GrpcBufPolicyTest.java' +git commit -m "build: add buf schema governance" +``` + +### Task 10: Spring Boot BOM 기반 Protobuf·gRPC Java Codegen + +**Files:** +- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcCodegenManifest.java` +- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcGeneratedPackagePolicy.java` +- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcCodegenOutput.java` +- Create: `modules/grpc/grpc-codegen/src/main/kotlin/io.backend.grpc-codegen-conventions.gradle.kts` +- Create: `modules/grpc/grpc-codegen/src/main/resources/META-INF/gradle-plugins/io.backend.grpc-codegen-conventions.properties` +- Test: `modules/grpc/grpc-codegen/src/test/java/io/backend/skeleton/grpc/codegen/GrpcCodegenManifestTest.java` + +**Interfaces:** +- Consumes: Task 9의 Buf policy와 Spring Boot 4.1 dependency management. +- Produces: protoc·grpc-java generation option, output package, descriptor path를 고정하는 build convention. + +**Implementation requirements:** +- Protobuf와 gRPC Java plugin version을 Boot BOM에서 읽고 중복 version 선언을 금지한다. +- generated source root는 build directory 아래에 둔다. +- hand-written package와 generated `*.proto` package를 겹치게 하지 않는다. +- Java code generation owner는 Boot-managed protobuf plugin 한 곳이다. +- descriptor set과 source info를 release artifact로 생성한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcCodegenManifestTest { + @org.junit.jupiter.api.Test + void bootBomOwnsToolVersions() { + var manifest = GrpcCodegenManifest.bootManaged(); + + org.assertj.core.api.Assertions.assertThat( + manifest.explicitProtocVersion()).isEmpty(); + org.assertj.core.api.Assertions.assertThat( + manifest.descriptorSetEnabled()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-codegen:test --tests 'io.backend.skeleton.grpc.codegen.GrpcCodegenManifestTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcCodegenManifest( + java.util.Optional explicitProtocVersion, + java.util.Optional explicitGrpcPluginVersion, + boolean descriptorSetEnabled, + boolean includeSourceInfo) { + + public static GrpcCodegenManifest bootManaged() { + return new GrpcCodegenManifest( + java.util.Optional.empty(), + java.util.Optional.empty(), + true, true); + } +} + +public record GrpcGeneratedPackagePolicy( + String protoPackagePrefix, + String javaPackagePrefix, + boolean separateFromHandwritten) {} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-codegen:test --tests 'io.backend.skeleton.grpc.codegen.GrpcCodegenManifestTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcCodegenManifest.java' 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcGeneratedPackagePolicy.java' 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcCodegenOutput.java' 'modules/grpc/grpc-codegen/src/main/kotlin/io.backend.grpc-codegen-conventions.gradle.kts' 'modules/grpc/grpc-codegen/src/main/resources/META-INF/gradle-plugins/io.backend.grpc-codegen-conventions.properties' 'modules/grpc/grpc-codegen/src/test/java/io/backend/skeleton/grpc/codegen/GrpcCodegenManifestTest.java' +git commit -m "build: standardize grpc java code generation" +``` + +### Task 11: Descriptor Artifact와 Consumer Source 호환성 + +**Files:** +- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcDescriptorArtifact.java` +- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcConsumerFixture.java` +- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcSchemaArtifactPublisher.java` +- Create: `modules/grpc/grpc-codegen/src/test/resources/consumer-fixtures/v1/build.gradle.kts` +- Create: `modules/grpc/grpc-codegen/src/test/resources/consumer-fixtures/v1/src/main/java/fixture/DocumentClientFixture.java` +- Test: `modules/grpc/grpc-codegen/src/test/java/io/backend/skeleton/grpc/codegen/GrpcDescriptorArtifactTest.java` + +**Interfaces:** +- Consumes: Task 10의 generated descriptor와 Task 9의 schema baseline. +- Produces: schema hash·descriptor·generated source hash·consumer compile 결과를 하나의 release artifact로 묶는 계약. + +**Implementation requirements:** +- FileDescriptorSet, Buf image, schema hash, policy version을 보존한다. +- 이전 Java client fixture를 새 generated sources와 함께 compile한다. +- service path, method path, Java package source break를 별도로 보고한다. +- artifact는 immutable version으로 publish한다. +- consumer fixture 실패는 release를 차단한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcDescriptorArtifactTest { + @org.junit.jupiter.api.Test + void artifactRequiresConsumerCompatibilityEvidence() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GrpcDescriptorArtifact( + "sha256:schema", "sha256:generated", + java.util.List.of())) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-codegen:test --tests 'io.backend.skeleton.grpc.codegen.GrpcDescriptorArtifactTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcConsumerFixture( + String name, + boolean compiled) {} + +public record GrpcDescriptorArtifact( + String schemaDigest, + String generatedSourceDigest, + java.util.List fixtures) { + + public GrpcDescriptorArtifact { + if (fixtures == null || fixtures.isEmpty() + || fixtures.stream().anyMatch( + fixture -> !fixture.compiled())) { + throw new IllegalArgumentException( + "consumer compatibility evidence required"); + } + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-codegen:test --tests 'io.backend.skeleton.grpc.codegen.GrpcDescriptorArtifactTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcDescriptorArtifact.java' 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcConsumerFixture.java' 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcSchemaArtifactPublisher.java' 'modules/grpc/grpc-codegen/src/test/resources/consumer-fixtures/v1/build.gradle.kts' 'modules/grpc/grpc-codegen/src/test/resources/consumer-fixtures/v1/src/main/java/fixture/DocumentClientFixture.java' 'modules/grpc/grpc-codegen/src/test/java/io/backend/skeleton/grpc/codegen/GrpcDescriptorArtifactTest.java' +git commit -m "build: publish grpc schema compatibility artifact" +``` + +### Task 12: Protovalidate Transport Validation + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/validation/GrpcTransportValidator.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/validation/GrpcValidationViolation.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/validation/ProtovalidateGrpcInterceptor.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/validation/ProtovalidateGrpcInterceptorTest.java` + +**Interfaces:** +- Consumes: Generated request messages, Task 8의 validation owner rule와 server interceptor SPI. +- Produces: pure transport constraint를 Application Use Case 진입 전에 검증하는 interceptor. + +**Implementation requirements:** +- 길이, 범위, collection count, format, pure cross-field rule만 처리한다. +- DB 조회·권한·현재 상태 검증은 금지한다. +- violation을 `INVALID_ARGUMENT`와 `BadRequest` detail로 변환한다. +- request body와 민감 field 값을 로그에 기록하지 않는다. +- validation rule compilation failure는 build 또는 startup failure다. + +- [ ] **Step 1: Write the failing test** + +```java +class ProtovalidateGrpcInterceptorTest { + @org.junit.jupiter.api.Test + void invalidRequestNeverReachesApplication() { + var validator = new GrpcTransportValidator( + value -> java.util.List.of( + new GrpcValidationViolation( + "title", "max_len", "too long"))); + + org.assertj.core.api.Assertions.assertThat( + validator.validate(new Object())).isNotEmpty(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.validation.ProtovalidateGrpcInterceptorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcValidationViolation( + String field, + String rule, + String safeMessage) {} + +public final class GrpcTransportValidator { + private final java.util.function.Function> delegate; + + public GrpcTransportValidator( + java.util.function.Function> delegate) { + this.delegate = delegate; + } + + public java.util.List validate( + Object request) { + return java.util.List.copyOf(delegate.apply(request)); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.validation.ProtovalidateGrpcInterceptorTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/validation/GrpcTransportValidator.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/validation/GrpcValidationViolation.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/validation/ProtovalidateGrpcInterceptor.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/validation/ProtovalidateGrpcInterceptorTest.java' +git commit -m "feat: add grpc transport validation" +``` + +### Task 13: Application 경계와 Raw gRPC API 차단 + +**Files:** +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/architecture/GrpcServiceAdapterMarker.java` +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/architecture/GrpcApplicationBoundaryRules.java` +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/architecture/GrpcRawApiImportRule.java` +- Test: `modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/architecture/GrpcApplicationBoundaryRulesTest.java` + +**Interfaces:** +- Consumes: Task 1 module graph, generated service classes와 Application package conventions. +- Produces: Service Adapter가 Use Case만 호출하고 DB·HTTP·Messaging·Storage에 직접 접근하지 못하게 하는 ArchUnit rule. + +**Implementation requirements:** +- gRPC service adapter는 `application` port만 의존한다. +- `EntityManager`, Repository, `MongoTemplate`, HTTP client, broker template, content store direct dependency를 금지한다. +- 일반 application module에서 raw `ManagedChannelBuilder`, `ServerBuilder`, `ClientCall`, `MethodDescriptor` import를 금지한다. +- generated package는 raw API import rule 예외다. +- architecture violation은 test failure다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcApplicationBoundaryRulesTest { + @org.junit.jupiter.api.Test + void rawBuildersAreNotApplicationApi() { + var rule = GrpcRawApiImportRule.defaultRule(); + + org.assertj.core.api.Assertions.assertThat( + rule.forbiddenTypes()) + .contains("io.grpc.ManagedChannelBuilder", + "io.grpc.ServerBuilder"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.architecture.GrpcApplicationBoundaryRulesTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public interface GrpcServiceAdapterMarker {} + +public record GrpcRawApiImportRule( + java.util.Set forbiddenTypes) { + + public static GrpcRawApiImportRule defaultRule() { + return new GrpcRawApiImportRule(java.util.Set.of( + "io.grpc.ManagedChannelBuilder", + "io.grpc.ServerBuilder", + "io.grpc.MethodDescriptor", + "io.grpc.ClientCall")); + } +} + +public final class GrpcApplicationBoundaryRules { + public static java.util.Set forbiddenDependencies() { + return java.util.Set.of( + "jakarta.persistence.EntityManager", + "org.springframework.data.repository.Repository", + "org.springframework.data.mongodb.core.MongoTemplate", + "org.springframework.web.client.RestClient", + "org.springframework.web.reactive.function.client.WebClient"); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.architecture.GrpcApplicationBoundaryRulesTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/architecture/GrpcServiceAdapterMarker.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/architecture/GrpcApplicationBoundaryRules.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/architecture/GrpcRawApiImportRule.java' 'modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/architecture/GrpcApplicationBoundaryRulesTest.java' +git commit -m "test: enforce grpc application boundary" +``` + +### Task 14: Typed Service Adapter SPI + +**Files:** +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServiceAdapter.java` +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcApplicationInvocation.java` +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcResponseMapper.java` +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServiceAdapterDescriptor.java` +- Test: `modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/server/GrpcServiceAdapterTest.java` + +**Interfaces:** +- Consumes: Task 12 architecture boundary, generated request/response와 Application Use Case port. +- Produces: Proto ↔ Application model 변환과 Use Case 호출만 수행하는 typed adapter contract. + +**Implementation requirements:** +- adapter는 request mapper, application invocation, response mapper를 명시한다. +- Application invocation에 immutable `GrpcRequestContext`를 전달한다. +- adapter가 transport Status를 직접 임의 구성하지 않고 공통 error mapper를 사용한다. +- blocking·async 실행 유형을 descriptor에 선언한다. +- 파일 bytes와 provider SDK 객체를 request/response mapping에 노출하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcServiceAdapterTest { + @org.junit.jupiter.api.Test + void adapterDelegatesToApplicationInvocation() { + var invoked = new java.util.concurrent.atomic.AtomicBoolean(); + GrpcApplicationInvocation invocation = + (command, context) -> { + invoked.set(true); + return "ok"; + }; + + org.assertj.core.api.Assertions.assertThat( + invocation.invoke("command", null)).isEqualTo("ok"); + org.assertj.core.api.Assertions.assertThat(invoked).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.server.GrpcServiceAdapterTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +@FunctionalInterface +public interface GrpcApplicationInvocation { + R invoke(C command, GrpcRequestContext context); +} + +public interface GrpcServiceAdapter + extends GrpcServiceAdapterMarker { + C toCommand(P request); + R invoke(C command, GrpcRequestContext context); + Q toResponse(R result); + GrpcServiceAdapterDescriptor descriptor(); +} + +public record GrpcServiceAdapterDescriptor( + GrpcMethodName method, + String executionProfile, + String responseProfile) {} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.server.GrpcServiceAdapterTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServiceAdapter.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcApplicationInvocation.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcResponseMapper.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServiceAdapterDescriptor.java' 'modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/server/GrpcServiceAdapterTest.java' +git commit -m "feat: add typed grpc service adapter" +``` + +### Task 15: Server Interceptor 순서 계약 + +**Files:** +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerInterceptorStage.java` +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerInterceptorOrder.java` +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerInterceptorChain.java` +- Test: `modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/server/GrpcServerInterceptorOrderTest.java` + +**Interfaces:** +- Consumes: Task 11 validation, Task 13 service adapter와 Security·Observability SPI. +- Produces: 예외 경계부터 validation과 service adapter까지의 의미 순서를 고정하는 chain. + +**Implementation requirements:** +- 순서는 Exception Boundary → Trace → Authentication → Actor/Tenant → Authorization → Admission → Deadline/Cancellation → Idempotency → Validation → Service Adapter다. +- 중복 stage와 누락 required stage를 startup에서 거부한다. +- 실제 framework wrapping 방향을 integration test로 검증한다. +- Exception mapper가 downstream 전체를 감싼다. +- client interceptor와 server interceptor 순서를 별도 catalog로 관리한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcServerInterceptorOrderTest { + @org.junit.jupiter.api.Test + void stableOrderPlacesValidationAfterIdempotency() { + var order = GrpcServerInterceptorOrder.stable(); + + org.assertj.core.api.Assertions.assertThat( + order.indexOf(GrpcServerInterceptorStage.VALIDATION)) + .isGreaterThan( + order.indexOf(GrpcServerInterceptorStage.IDEMPOTENCY)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.server.GrpcServerInterceptorOrderTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcServerInterceptorStage { + EXCEPTION_BOUNDARY, TRACE, AUTHENTICATION, + ACTOR_TENANT, AUTHORIZATION, ADMISSION, + DEADLINE_CANCELLATION, IDEMPOTENCY, + VALIDATION, SERVICE_ADAPTER +} + +public record GrpcServerInterceptorOrder( + java.util.List stages) { + + public static GrpcServerInterceptorOrder stable() { + return new GrpcServerInterceptorOrder( + java.util.List.of( + GrpcServerInterceptorStage.EXCEPTION_BOUNDARY, + GrpcServerInterceptorStage.TRACE, + GrpcServerInterceptorStage.AUTHENTICATION, + GrpcServerInterceptorStage.ACTOR_TENANT, + GrpcServerInterceptorStage.AUTHORIZATION, + GrpcServerInterceptorStage.ADMISSION, + GrpcServerInterceptorStage.DEADLINE_CANCELLATION, + GrpcServerInterceptorStage.IDEMPOTENCY, + GrpcServerInterceptorStage.VALIDATION, + GrpcServerInterceptorStage.SERVICE_ADAPTER)); + } + + public int indexOf(GrpcServerInterceptorStage stage) { + return stages.indexOf(stage); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.server.GrpcServerInterceptorOrderTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerInterceptorStage.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerInterceptorOrder.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerInterceptorChain.java' 'modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/server/GrpcServerInterceptorOrderTest.java' +git commit -m "feat: fix grpc server interceptor order" +``` + +### Task 16: Context Propagation과 Thread 경계 + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/context/GrpcContextBinder.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/context/GrpcContextSnapshot.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/context/GrpcContextPropagationPolicy.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/context/GrpcContextBinderTest.java` + +**Interfaces:** +- Consumes: Task 7 request context와 Task 15 interceptor order. +- Produces: gRPC Context, SecurityContext, Reactor Context, executor 경계에서 Actor·Tenant·Deadline을 보존하는 binder. + +**Implementation requirements:** +- context snapshot은 immutable하며 credential 원문을 포함하지 않는다. +- blocking executor와 virtual thread에 context를 복원하고 작업 종료 후 정리한다. +- Reactor adapter가 있을 때 Reactor Context와 gRPC Context를 명시적으로 bridge한다. +- context가 없는 비동기 작업은 fail-closed한다. +- stream 수명 동안 actor·tenant·credential expiry를 보존한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcContextBinderTest { + @org.junit.jupiter.api.Test + void missingContextFailsClosed() { + var binder = new GrpcContextBinder(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + binder::current) + .isInstanceOf(IllegalStateException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.context.GrpcContextBinderTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcContextSnapshot( + GrpcRequestContext requestContext, + java.time.Instant capturedAt) {} + +public final class GrpcContextBinder { + private static final ThreadLocal CURRENT = + new ThreadLocal<>(); + + public GrpcContextSnapshot current() { + var value = CURRENT.get(); + if (value == null) { + throw new IllegalStateException( + "gRPC request context is required"); + } + return value; + } + + public AutoCloseable bind(GrpcContextSnapshot value) { + CURRENT.set(value); + return CURRENT::remove; + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.context.GrpcContextBinderTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/context/GrpcContextBinder.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/context/GrpcContextSnapshot.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/context/GrpcContextPropagationPolicy.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/context/GrpcContextBinderTest.java' +git commit -m "feat: propagate grpc request context" +``` + +### Task 17: Status와 Rich Error Mapper + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/error/GrpcStatusMapping.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/error/GrpcRichErrorDetail.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/error/GrpcErrorMapper.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/error/GrpcErrorExposurePolicy.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/error/GrpcErrorMapperTest.java` + +**Interfaces:** +- Consumes: Task 5 failure model과 Application exception hierarchy. +- Produces: 안정된 gRPC Status와 allowlisted google.rpc detail로 내부 오류를 변환하는 mapper. + +**Implementation requirements:** +- validation은 `INVALID_ARGUMENT`, state precondition은 `FAILED_PRECONDITION`, concurrency abort는 `ABORTED`로 매핑한다. +- retry hint는 `RetryInfo` detail로만 노출한다. +- unknown exception은 `INTERNAL`과 opaque execution ID로 변환한다. +- stack, SQL, query, host, token, PII를 detail에서 제거한다. +- client는 message 문자열이 아니라 code·reason·typed detail로 분기한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcErrorMapperTest { + @org.junit.jupiter.api.Test + void unknownFailureIsOpaqueInternalError() { + var mapped = new GrpcErrorMapper( + GrpcErrorExposurePolicy.safe()) + .map(new RuntimeException("jdbc:secret")); + + org.assertj.core.api.Assertions.assertThat(mapped.code()) + .isEqualTo("INTERNAL"); + org.assertj.core.api.Assertions.assertThat(mapped.message()) + .doesNotContain("jdbc"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.error.GrpcErrorMapperTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcStatusMapping( + String code, + String message, + java.util.List details) {} + +public record GrpcRichErrorDetail( + String type, + java.util.Map safeFields) {} + +public record GrpcErrorExposurePolicy( + java.util.Set allowedDetailTypes) { + + public static GrpcErrorExposurePolicy safe() { + return new GrpcErrorExposurePolicy(java.util.Set.of( + "BadRequest", "PreconditionFailure", "RetryInfo", + "ResourceInfo", "ErrorInfo", "QuotaFailure", + "LocalizedMessage")); + } +} + +public final class GrpcErrorMapper { + public GrpcErrorMapper(GrpcErrorExposurePolicy policy) {} + public GrpcStatusMapping map(Throwable error) { + return new GrpcStatusMapping( + "INTERNAL", "RPC execution failed", + java.util.List.of(new GrpcRichErrorDetail( + "ErrorInfo", + java.util.Map.of("reason", "INTERNAL_ERROR")))); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.error.GrpcErrorMapperTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/error/GrpcStatusMapping.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/error/GrpcRichErrorDetail.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/error/GrpcErrorMapper.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/error/GrpcErrorExposurePolicy.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/error/GrpcErrorMapperTest.java' +git commit -m "feat: add grpc status and rich error mapping" +``` + +### Task 18: Netty Server Profile·Executor·Admission + +**Files:** +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerProfile.java` +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerTransport.java` +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcExecutorProfile.java` +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcAdmissionController.java` +- Test: `modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/server/GrpcServerProfileTest.java` + +**Interfaces:** +- Consumes: Task 15 interceptor chain과 Spring Boot gRPC server customization. +- Produces: Netty Stable server의 size, metadata, concurrency, executor, keepalive, drain 설정. + +**Implementation requirements:** +- Netty를 Stable 기본 transport로 사용한다. +- blocking Use Case는 bounded executor에서 실행하며 event loop 직접 실행을 금지한다. +- queue와 concurrent call limit을 넘으면 `RESOURCE_EXHAUSTED`로 거부한다. +- `directExecutor`와 unbounded queue를 production에서 금지한다. +- message·metadata·keepalive·connection age·shutdown 설정을 profile로 검증한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcServerProfileTest { + @org.junit.jupiter.api.Test + void productionRequiresBoundedExecutorAndLimits() { + var profile = GrpcServerProfile.stableNetty(); + + org.assertj.core.api.Assertions.assertThat( + profile.maxConcurrentCalls()).isPositive(); + org.assertj.core.api.Assertions.assertThat( + profile.executor().bounded()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.server.GrpcServerProfileTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcServerTransport { + NETTY, NETTY_SHADED, SERVLET, IN_PROCESS +} + +public record GrpcExecutorProfile( + String name, + int maxConcurrency, + int queueCapacity, + boolean bounded) {} + +public record GrpcServerProfile( + GrpcServerTransport transport, + int maxInboundMessageBytes, + int maxInboundMetadataBytes, + int maxConcurrentCalls, + GrpcExecutorProfile executor) { + + public static GrpcServerProfile stableNetty() { + return new GrpcServerProfile( + GrpcServerTransport.NETTY, + 4 * 1024 * 1024, + 8 * 1024, + 512, + new GrpcExecutorProfile( + "blocking-bounded", 256, 256, true)); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.server.GrpcServerProfileTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerProfile.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerTransport.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcExecutorProfile.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcAdmissionController.java' 'modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/server/GrpcServerProfileTest.java' +git commit -m "feat: configure stable grpc netty server" +``` + +### Task 19: Netty Shaded Transport 호환 Profile + +**Files:** +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcNettyVariant.java` +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcNettyVariantSelector.java` +- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcNettyParityContract.java` +- Test: `modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/server/GrpcNettyVariantSelectorTest.java` + +**Interfaces:** +- Consumes: Task 18 Stable Netty profile. +- Produces: `grpc-netty`와 `grpc-netty-shaded`를 상호 배타적으로 선택하고 기능 동등성을 검증하는 profile. + +**Implementation requirements:** +- 한 runtime에 unshaded와 shaded transport가 동시에 활성화되지 않게 한다. +- TLS, metadata limit, message limit, health, reflection, graceful shutdown parity를 검증한다. +- shaded profile은 dependency conflict 회피 목적이며 기능을 추가하지 않는다. +- variant 선택은 build/runtime profile에서 명시한다. +- 실제 Netty integration suite를 두 variant에 실행한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcNettyVariantSelectorTest { + @org.junit.jupiter.api.Test + void twoNettyVariantsCannotBeEnabledTogether() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GrpcNettyVariantSelector( + java.util.Set.of( + GrpcNettyVariant.NETTY, + GrpcNettyVariant.NETTY_SHADED))) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.server.GrpcNettyVariantSelectorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcNettyVariant { + NETTY, NETTY_SHADED +} + +public record GrpcNettyVariantSelector( + java.util.Set enabled) { + public GrpcNettyVariantSelector { + if (enabled == null || enabled.size() != 1) { + throw new IllegalArgumentException( + "exactly one Netty variant is required"); + } + } +} + +public record GrpcNettyParityContract( + boolean tls, + boolean metadataLimit, + boolean gracefulShutdown) {} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.server.GrpcNettyVariantSelectorTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcNettyVariant.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcNettyVariantSelector.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcNettyParityContract.java' 'modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/server/GrpcNettyVariantSelectorTest.java' +git commit -m "build: add shaded netty compatibility profile" +``` + +### Task 20: TLS·mTLS·CallCredentials와 Rotation + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/security/GrpcTlsProfile.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/security/GrpcAuthenticationProfile.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/security/GrpcCredentialGeneration.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/security/GrpcCredentialRotationManager.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/security/GrpcTlsProfileTest.java` + +**Interfaces:** +- Consumes: Spring Boot SSL Bundle, Security token source와 Task 24 예정 Named Channel profile. +- Produces: TLS/mTLS와 per-call credential을 분리하고 generation 기반으로 교체하는 security contract. + +**Implementation requirements:** +- dev·stage·prod에서 TLS를 필수로 한다. +- mTLS는 service identity가 필요한 profile에서 사용한다. +- Bearer/JWT/service token은 `CallCredentials` 또는 승인된 credential adapter가 생성한다. +- trust-all과 hostname verification 비활성화를 production에서 startup failure로 처리한다. +- credential·certificate 교체 시 새 generation을 만들고 기존 call/stream을 drain한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcTlsProfileTest { + @org.junit.jupiter.api.Test + void productionRejectsTrustAll() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GrpcTlsProfile.production( + "internal-ca", false, true)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.security.GrpcTlsProfileTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcTlsProfile( + String sslBundle, + boolean hostnameVerification, + boolean trustAll, + boolean mutualTls) { + + public static GrpcTlsProfile production( + String bundle, + boolean hostnameVerification, + boolean trustAll) { + if (!hostnameVerification || trustAll) { + throw new IllegalArgumentException( + "secure TLS verification is required"); + } + return new GrpcTlsProfile( + bundle, true, false, false); + } +} + +public record GrpcCredentialGeneration( + long generation, + java.time.Instant createdAt) {} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.security.GrpcTlsProfileTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/security/GrpcTlsProfile.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/security/GrpcAuthenticationProfile.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/security/GrpcCredentialGeneration.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/security/GrpcCredentialRotationManager.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/security/GrpcTlsProfileTest.java' +git commit -m "feat: add grpc tls and credential rotation" +``` + +### Task 21: 표준 Health Service와 Service 상태 + +**Files:** +- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcHealthState.java` +- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcServiceHealthRegistry.java` +- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcHealthPolicy.java` +- Test: `modules/grpc/grpc-admin/src/test/java/io/backend/skeleton/grpc/admin/GrpcServiceHealthRegistryTest.java` + +**Interfaces:** +- Consumes: Server service catalog와 dependency health signals. +- Produces: global·service별 health, readiness, draining과 Watch를 제공하는 표준 health registry. + +**Implementation requirements:** +- 상태는 `UNKNOWN`, `SERVING`, `NOT_SERVING`, `SERVICE_UNKNOWN`, `DRAINING`으로 표현한다. +- startup 중에는 요청을 받기 전에 readiness를 false로 유지한다. +- drain 시작 시 service health를 먼저 `DRAINING/NOT_SERVING`으로 전환한다. +- dependency health가 business correctness에 필수일 때만 service health에 반영한다. +- health endpoint의 network·credential 접근 정책을 business RPC와 분리한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcServiceHealthRegistryTest { + @org.junit.jupiter.api.Test + void drainingServiceIsNotReadyForNewCalls() { + var registry = new GrpcServiceHealthRegistry(); + registry.draining("DocumentService"); + + org.assertj.core.api.Assertions.assertThat( + registry.state("DocumentService")) + .isEqualTo(GrpcHealthState.DRAINING); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-admin:test --tests 'io.backend.skeleton.grpc.admin.GrpcServiceHealthRegistryTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcHealthState { + UNKNOWN, SERVING, NOT_SERVING, + SERVICE_UNKNOWN, DRAINING +} + +public final class GrpcServiceHealthRegistry { + private final java.util.concurrent.ConcurrentMap states = + new java.util.concurrent.ConcurrentHashMap<>(); + + public void draining(String service) { + states.put(service, GrpcHealthState.DRAINING); + } + + public GrpcHealthState state(String service) { + return states.getOrDefault( + service, GrpcHealthState.UNKNOWN); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-admin:test --tests 'io.backend.skeleton.grpc.admin.GrpcServiceHealthRegistryTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcHealthState.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcServiceHealthRegistry.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcHealthPolicy.java' 'modules/grpc/grpc-admin/src/test/java/io/backend/skeleton/grpc/admin/GrpcServiceHealthRegistryTest.java' +git commit -m "feat: add grpc health registry" +``` + +### Task 22: Reflection 환경·권한 정책 + +**Files:** +- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcReflectionMode.java` +- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcReflectionPolicy.java` +- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcReflectionAccessDecision.java` +- Test: `modules/grpc/grpc-admin/src/test/java/io/backend/skeleton/grpc/admin/GrpcReflectionPolicyTest.java` + +**Interfaces:** +- Consumes: Schema descriptor artifact와 environment/security profile. +- Produces: Local/Test 허용, Dev 관리자 인증, Production 기본 비활성인 reflection policy. + +**Implementation requirements:** +- Reflection mode는 `ENABLED`, `ADMIN_ONLY`, `DISABLED`다. +- production default는 `DISABLED`다. +- Admin-only reflection은 별도 network/role predicate를 요구한다. +- reflection 공개 여부와 실제 RPC authorization을 동일시하지 않는다. +- schema descriptor version을 admin snapshot에 노출하되 secret은 제외한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcReflectionPolicyTest { + @org.junit.jupiter.api.Test + void productionDefaultsToDisabled() { + org.assertj.core.api.Assertions.assertThat( + GrpcReflectionPolicy.production().mode()) + .isEqualTo(GrpcReflectionMode.DISABLED); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-admin:test --tests 'io.backend.skeleton.grpc.admin.GrpcReflectionPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcReflectionMode { + ENABLED, ADMIN_ONLY, DISABLED +} + +public record GrpcReflectionPolicy( + GrpcReflectionMode mode, + java.util.Set requiredRoles) { + + public static GrpcReflectionPolicy production() { + return new GrpcReflectionPolicy( + GrpcReflectionMode.DISABLED, + java.util.Set.of()); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-admin:test --tests 'io.backend.skeleton.grpc.admin.GrpcReflectionPolicyTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcReflectionMode.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcReflectionPolicy.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcReflectionAccessDecision.java' 'modules/grpc/grpc-admin/src/test/java/io/backend/skeleton/grpc/admin/GrpcReflectionPolicyTest.java' +git commit -m "feat: govern grpc reflection access" +``` + +### Task 23: Graceful Shutdown과 Stream Drain + +**Files:** +- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcDrainPhase.java` +- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcDrainPolicy.java` +- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcDrainCoordinator.java` +- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcDrainResult.java` +- Test: `modules/grpc/grpc-admin/src/test/java/io/backend/skeleton/grpc/admin/GrpcDrainCoordinatorTest.java` + +**Interfaces:** +- Consumes: Task 21 health registry, server call registry와 streaming session registry. +- Produces: readiness 차단부터 unary·stream drain과 force cancel까지의 deterministic shutdown. + +**Implementation requirements:** +- 순서는 readiness false → health draining → new admission reject → unary drain → stream signal → force cancel이다. +- drain과 force timeout을 분리한다. +- stream에는 마지막 sequence·resume cursor 또는 full resync 필요 상태를 전달한다. +- drain 중 새 retry·new stream을 시작하지 않는다. +- drain 결과에 completed·cancelled·timed-out call 수를 보존한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcDrainCoordinatorTest { + @org.junit.jupiter.api.Test + void healthChangesBeforeNewAdmissionStops() { + var coordinator = new GrpcDrainCoordinator( + GrpcDrainPolicy.standard()); + var phases = coordinator.plan(); + + org.assertj.core.api.Assertions.assertThat(phases) + .containsSubsequence( + GrpcDrainPhase.READINESS_OFF, + GrpcDrainPhase.HEALTH_DRAINING, + GrpcDrainPhase.ADMISSION_CLOSED); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-admin:test --tests 'io.backend.skeleton.grpc.admin.GrpcDrainCoordinatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcDrainPhase { + READINESS_OFF, + HEALTH_DRAINING, + ADMISSION_CLOSED, + UNARY_DRAIN, + STREAM_SIGNAL, + FORCE_CANCEL, + COMPLETE +} + +public record GrpcDrainPolicy( + java.time.Duration drainTimeout, + java.time.Duration forceTimeout) { + public static GrpcDrainPolicy standard() { + return new GrpcDrainPolicy( + java.time.Duration.ofSeconds(30), + java.time.Duration.ofSeconds(5)); + } +} + +public final class GrpcDrainCoordinator { + public GrpcDrainCoordinator(GrpcDrainPolicy policy) {} + public java.util.List plan() { + return java.util.List.of( + GrpcDrainPhase.READINESS_OFF, + GrpcDrainPhase.HEALTH_DRAINING, + GrpcDrainPhase.ADMISSION_CLOSED, + GrpcDrainPhase.UNARY_DRAIN, + GrpcDrainPhase.STREAM_SIGNAL, + GrpcDrainPhase.FORCE_CANCEL, + GrpcDrainPhase.COMPLETE); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-admin:test --tests 'io.backend.skeleton.grpc.admin.GrpcDrainCoordinatorTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcDrainPhase.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcDrainPolicy.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcDrainCoordinator.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcDrainResult.java' 'modules/grpc/grpc-admin/src/test/java/io/backend/skeleton/grpc/admin/GrpcDrainCoordinatorTest.java' +git commit -m "feat: implement grpc graceful drain" +``` + +### Task 24: Named Channel Profile + +**Files:** +- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcNamedChannelProfile.java` +- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcLoadBalancingPolicy.java` +- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcRetryOwner.java` +- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelProfileValidator.java` +- Test: `modules/grpc/grpc-client/src/test/java/io/backend/skeleton/grpc/client/GrpcNamedChannelProfileTest.java` + +**Interfaces:** +- Consumes: Task 3 method policies, Task 20 TLS profiles와 deployment configuration. +- Produces: logical channel name별 target, transport, LB, TLS, deadline, retry owner, size limit 계약. + +**Implementation requirements:** +- target은 resolver scheme을 포함한 URI 형태로 검증한다. +- load balancing은 Stable에서 `PICK_FIRST`, `ROUND_ROBIN`만 허용한다. +- retry owner는 `GRPC_PLATFORM`, `SERVICE_MESH`, `APPLICATION`, `NONE` 중 하나다. +- TLS, metadata/message limit, wait-for-ready, observability 설정을 profile에 고정한다. +- 동일 service라도 long stream과 short unary의 SLO가 다르면 별도 channel profile을 사용한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcNamedChannelProfileTest { + @org.junit.jupiter.api.Test + void meshOwnedRetryCannotAlsoUseGrpcRetry() { + var profile = GrpcNamedChannelProfile.mesh( + new GrpcChannelProfileName("document")); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GrpcChannelProfileValidator() + .validate(profile, true)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-client:test --tests 'io.backend.skeleton.grpc.client.GrpcNamedChannelProfileTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcLoadBalancingPolicy { + PICK_FIRST, ROUND_ROBIN +} + +public enum GrpcRetryOwner { + GRPC_PLATFORM, SERVICE_MESH, APPLICATION, NONE +} + +public record GrpcNamedChannelProfile( + GrpcChannelProfileName name, + String target, + GrpcLoadBalancingPolicy loadBalancing, + GrpcRetryOwner retryOwner, + GrpcTlsProfile tls, + int maxInboundMessageBytes, + int maxMetadataBytes) { + + public static GrpcNamedChannelProfile mesh( + GrpcChannelProfileName name) { + return new GrpcNamedChannelProfile( + name, "dns:///service:9090", + GrpcLoadBalancingPolicy.PICK_FIRST, + GrpcRetryOwner.SERVICE_MESH, + GrpcTlsProfile.production( + "internal-ca", true, false), + 4 * 1024 * 1024, 8 * 1024); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-client:test --tests 'io.backend.skeleton.grpc.client.GrpcNamedChannelProfileTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcNamedChannelProfile.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcLoadBalancingPolicy.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcRetryOwner.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelProfileValidator.java' 'modules/grpc/grpc-client/src/test/java/io/backend/skeleton/grpc/client/GrpcNamedChannelProfileTest.java' +git commit -m "feat: define grpc named channel profiles" +``` + +### Task 25: Channel Runtime Generation과 Drain + +**Files:** +- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelGeneration.java` +- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelRuntime.java` +- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelRuntimeRegistry.java` +- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelDrainPolicy.java` +- Test: `modules/grpc/grpc-client/src/test/java/io/backend/skeleton/grpc/client/GrpcChannelRuntimeRegistryTest.java` + +**Interfaces:** +- Consumes: Task 24 Named Channel Profile과 credential generation. +- Produces: Channel·Stub을 재사용하고 설정·인증서 교체 시 새 generation으로 무중단 전환하는 runtime registry. + +**Implementation requirements:** +- 요청마다 channel을 생성하지 않는다. +- profile generation 변경 시 새 runtime을 준비한 뒤 atomic pointer를 교체한다. +- 기존 runtime은 in-flight unary와 stream을 drain한다. +- drain timeout 후 남은 call을 cancel하고 metric을 기록한다. +- runtime registry는 credential·raw builder를 application에 반환하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcChannelRuntimeRegistryTest { + @org.junit.jupiter.api.Test + void rotationCreatesNewGenerationBeforeOldDrain() { + var registry = new GrpcChannelRuntimeRegistry(); + var first = registry.install("document"); + var second = registry.rotate("document"); + + org.assertj.core.api.Assertions.assertThat( + second.generation()).isGreaterThan(first.generation()); + org.assertj.core.api.Assertions.assertThat( + registry.current("document")).isEqualTo(second); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-client:test --tests 'io.backend.skeleton.grpc.client.GrpcChannelRuntimeRegistryTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcChannelGeneration(long value) {} + +public record GrpcChannelRuntime( + String profile, + GrpcChannelGeneration generation, + boolean acceptingCalls) {} + +public final class GrpcChannelRuntimeRegistry { + private final java.util.concurrent.ConcurrentMap current = + new java.util.concurrent.ConcurrentHashMap<>(); + + public GrpcChannelRuntime install(String profile) { + var runtime = new GrpcChannelRuntime( + profile, new GrpcChannelGeneration(1), true); + current.put(profile, runtime); + return runtime; + } + + public GrpcChannelRuntime rotate(String profile) { + var old = current(profile); + var next = new GrpcChannelRuntime( + profile, + new GrpcChannelGeneration( + old.generation().value() + 1), + true); + current.put(profile, next); + return next; + } + + public GrpcChannelRuntime current(String profile) { + return current.get(profile); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-client:test --tests 'io.backend.skeleton.grpc.client.GrpcChannelRuntimeRegistryTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelGeneration.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelRuntime.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelRuntimeRegistry.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelDrainPolicy.java' 'modules/grpc/grpc-client/src/test/java/io/backend/skeleton/grpc/client/GrpcChannelRuntimeRegistryTest.java' +git commit -m "feat: manage grpc channel generations" +``` + +### Task 26: Typed Stub Factory와 Method Policy 적용 + +**Files:** +- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcTypedStubFactory.java` +- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcStubDescriptor.java` +- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcStubPolicyApplier.java` +- Test: `modules/grpc/grpc-client/src/test/java/io/backend/skeleton/grpc/client/GrpcTypedStubFactoryTest.java` + +**Interfaces:** +- Consumes: Task 24 channel profile, Task 25 runtime registry, generated stub classes. +- Produces: registered generated stub만 생성하고 method policy·deadline·credentials를 적용하는 typed factory. + +**Implementation requirements:** +- stub factory는 allowlisted generated service type만 생성한다. +- stub마다 Named Channel Profile과 method policy catalog를 연결한다. +- raw Channel·ManagedChannel과 builder를 application에 반환하지 않는다. +- blocking, future, async stub 유형을 descriptor로 구분한다. +- method 호출 직전에 effective deadline과 call credentials를 적용한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcTypedStubFactoryTest { + @org.junit.jupiter.api.Test + void unregisteredStubTypeIsRejected() { + var factory = GrpcTypedStubFactory.empty(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> factory.create(Object.class, "document")) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-client:test --tests 'io.backend.skeleton.grpc.client.GrpcTypedStubFactoryTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcStubDescriptor( + Class stubType, + String profile, + String style) {} + +public final class GrpcTypedStubFactory { + private final java.util.Map, GrpcStubDescriptor> types; + + private GrpcTypedStubFactory( + java.util.Map, GrpcStubDescriptor> types) { + this.types = types; + } + + public static GrpcTypedStubFactory empty() { + return new GrpcTypedStubFactory(java.util.Map.of()); + } + + public T create(Class type, String profile) { + if (!types.containsKey(type)) { + throw new IllegalArgumentException( + "unregistered generated stub"); + } + throw new UnsupportedOperationException( + "transport adapter creates the generated stub"); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-client:test --tests 'io.backend.skeleton.grpc.client.GrpcTypedStubFactoryTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcTypedStubFactory.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcStubDescriptor.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcStubPolicyApplier.java' 'modules/grpc/grpc-client/src/test/java/io/backend/skeleton/grpc/client/GrpcTypedStubFactoryTest.java' +git commit -m "feat: add grpc typed stub factory" +``` + +### Task 27: Client Metadata와 CallCredentials + +**Files:** +- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcClientMetadataPolicy.java` +- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcCallCredentialProvider.java` +- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcClientCallContext.java` +- Test: `modules/grpc/grpc-client/src/test/java/io/backend/skeleton/grpc/client/GrpcClientMetadataPolicyTest.java` + +**Interfaces:** +- Consumes: Task 7 metadata keys, Task 20 credential profiles와 Task 26 stub factory. +- Produces: trace·correlation·idempotency metadata와 per-call credential을 안전하게 materialize하는 client policy. + +**Implementation requirements:** +- Authorization은 application-provided generic header가 아니라 credential provider가 생성한다. +- tenant·actor context는 allowlisted signed/validated representation만 전달한다. +- cross-trust-boundary channel에서 baggage·tenant metadata allowlist를 별도 적용한다. +- metadata budget을 call 시작 전에 검증한다. +- token, cookie, password와 raw PII를 log·metric에 기록하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcClientMetadataPolicyTest { + @org.junit.jupiter.api.Test + void applicationCannotOverrideAuthorizationMetadata() { + var policy = GrpcClientMetadataPolicy.standard(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> policy.validateUserKey("authorization")) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-client:test --tests 'io.backend.skeleton.grpc.client.GrpcClientMetadataPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcClientMetadataPolicy( + java.util.Set reservedKeys, + GrpcMetadataBudget budget) { + + public static GrpcClientMetadataPolicy standard() { + return new GrpcClientMetadataPolicy( + java.util.Set.of( + "authorization", "grpc-timeout", + "traceparent", "tracestate"), + new GrpcMetadataBudget(8192, 4096)); + } + + public void validateUserKey(String key) { + if (reservedKeys.contains(key.toLowerCase())) { + throw new IllegalArgumentException( + "reserved gRPC metadata key"); + } + } +} + +public interface GrpcCallCredentialProvider { + Object credentials(GrpcClientCallContext context); +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-client:test --tests 'io.backend.skeleton.grpc.client.GrpcClientMetadataPolicyTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcClientMetadataPolicy.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcCallCredentialProvider.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcClientCallContext.java' 'modules/grpc/grpc-client/src/test/java/io/backend/skeleton/grpc/client/GrpcClientMetadataPolicyTest.java' +git commit -m "feat: secure grpc client metadata" +``` + +### Task 28: Deadline 전파와 Dependency Budget + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineCalculator.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDependencyBudget.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlinePolicyValidator.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineCalculatorTest.java` + +**Interfaces:** +- Consumes: Task 6 deadline primitive, Task 3 method policy와 current gRPC Context. +- Produces: parent remaining deadline과 method profile에서 downstream call budget을 계산하는 policy. + +**Implementation requirements:** +- effective deadline은 parent remaining과 method default 중 더 짧다. +- safety margin과 response/trailer reserve를 차감한다. +- DB·HTTP·downstream gRPC timeout이 inbound deadline을 초과하면 startup 검증에서 실패한다. +- minimum attempt budget보다 짧으면 call을 시작하지 않는다. +- deadline이 없는 Stable Unary call을 거부한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcDeadlineCalculatorTest { + @org.junit.jupiter.api.Test + void childDeadlineReservesResponseBudget() { + var calculator = new GrpcDeadlineCalculator( + java.time.Duration.ofMillis(100)); + + var child = calculator.calculate( + java.time.Duration.ofSeconds(2), + java.time.Duration.ofSeconds(5)); + + org.assertj.core.api.Assertions.assertThat(child) + .isLessThanOrEqualTo( + java.time.Duration.ofMillis(1900)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.deadline.GrpcDeadlineCalculatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GrpcDeadlineCalculator { + private final java.time.Duration reserve; + + public GrpcDeadlineCalculator( + java.time.Duration reserve) { + this.reserve = reserve; + } + + public java.time.Duration calculate( + java.time.Duration parentRemaining, + java.time.Duration methodDefault) { + var minimum = parentRemaining.compareTo(methodDefault) < 0 + ? parentRemaining : methodDefault; + var result = minimum.minus(reserve); + if (result.isZero() || result.isNegative()) { + throw new GrpcDeadlineExceededException( + "insufficient downstream budget"); + } + return result; + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.deadline.GrpcDeadlineCalculatorTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineCalculator.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDependencyBudget.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlinePolicyValidator.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineCalculatorTest.java' +git commit -m "feat: propagate grpc deadline budgets" +``` + +### Task 29: Cancellation 전파와 Side-effect 차단 + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcCancellationCoordinator.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcCancellableOperation.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcCancellationReason.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/deadline/GrpcCancellationCoordinatorTest.java` + +**Interfaces:** +- Consumes: Task 6 cancellation token과 Application dependency cancellation adapters. +- Produces: client cancel/deadline을 Future·Publisher·HTTP·DB 작업에 전파하고 새 side effect 시작을 차단하는 coordinator. + +**Implementation requirements:** +- cancel은 idempotent하며 첫 reason을 보존한다. +- Application Use Case는 cancellation token을 인자로 받거나 context에서 조회한다. +- cancel 이후 신규 외부 side effect를 시작하지 않는다. +- commit 경계 이후 cancel은 business evidence를 자동 ABORT로 바꾸지 않는다. +- stream source와 writer를 모두 cancel하고 queue를 정리한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcCancellationCoordinatorTest { + @org.junit.jupiter.api.Test + void cancellationInvokesRegisteredOperationsOnce() { + var calls = new java.util.concurrent.atomic.AtomicInteger(); + var coordinator = new GrpcCancellationCoordinator(); + coordinator.register(calls::incrementAndGet); + + coordinator.cancel(GrpcCancellationReason.CLIENT_CANCELLED); + coordinator.cancel(GrpcCancellationReason.DEADLINE); + + org.assertj.core.api.Assertions.assertThat(calls).hasValue(1); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.deadline.GrpcCancellationCoordinatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcCancellationReason { + CLIENT_CANCELLED, DEADLINE, SERVER_DRAIN, POLICY +} + +@FunctionalInterface +public interface GrpcCancellableOperation { + void cancel(); +} + +public final class GrpcCancellationCoordinator { + private final java.util.List operations = + new java.util.concurrent.CopyOnWriteArrayList<>(); + private final java.util.concurrent.atomic.AtomicBoolean cancelled = + new java.util.concurrent.atomic.AtomicBoolean(); + + public void register(GrpcCancellableOperation operation) { + if (cancelled.get()) { + operation.cancel(); + } else { + operations.add(operation); + } + } + + public void cancel(GrpcCancellationReason reason) { + if (cancelled.compareAndSet(false, true)) { + operations.forEach(GrpcCancellableOperation::cancel); + } + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.deadline.GrpcCancellationCoordinatorTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcCancellationCoordinator.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcCancellableOperation.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcCancellationReason.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/deadline/GrpcCancellationCoordinatorTest.java' +git commit -m "feat: propagate grpc cancellation" +``` + +### Task 30: Service Config와 Retry Owner 검증 + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcServiceConfigPolicy.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcMethodRetryConfig.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryOwnershipValidator.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/resilience/GrpcRetryOwnershipValidatorTest.java` + +**Interfaces:** +- Consumes: Task 24 channel retry owner와 Task 3 method policy. +- Produces: method별 timeout·wait-for-ready·retry를 하나의 owner만 관리하게 하는 Service Config contract. + +**Implementation requirements:** +- APPLICATION, GRPC_PLATFORM, SERVICE_MESH, NONE 중 explicit retry owner는 하나다. +- Service Config의 method name이 policy catalog와 일치해야 한다. +- mesh owner일 때 gRPC explicit retry와 hedging을 비활성화한다. +- retry policy가 없는 것과 channel retry 자체를 disable한 것을 구분한다. +- transparent retry 존재 여부를 운영 snapshot에 기록한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcRetryOwnershipValidatorTest { + @org.junit.jupiter.api.Test + void twoExplicitRetryOwnersAreRejected() { + var validator = new GrpcRetryOwnershipValidator(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> validator.validate( + java.util.Set.of( + GrpcRetryOwner.GRPC_PLATFORM, + GrpcRetryOwner.SERVICE_MESH))) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.resilience.GrpcRetryOwnershipValidatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcMethodRetryConfig( + GrpcMethodName method, + int maxAttempts, + java.time.Duration initialBackoff, + java.util.Set retryableStatusCodes) {} + +public final class GrpcRetryOwnershipValidator { + public void validate( + java.util.Set explicitOwners) { + var count = explicitOwners.stream() + .filter(owner -> owner != GrpcRetryOwner.NONE) + .count(); + if (count > 1) { + throw new IllegalArgumentException( + "one explicit retry owner is required"); + } + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.resilience.GrpcRetryOwnershipValidatorTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcServiceConfigPolicy.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcMethodRetryConfig.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryOwnershipValidator.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/resilience/GrpcRetryOwnershipValidatorTest.java' +git commit -m "feat: validate grpc retry ownership" +``` + +### Task 31: Retry Eligibility와 Budget + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryDecision.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryBudget.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryEligibility.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryCoordinator.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/resilience/GrpcRetryEligibilityTest.java` + +**Interfaces:** +- Consumes: Task 3 idempotency profile, Task 4 evidence, Task 29 retry ownership과 deadline budget. +- Produces: status·idempotency·evidence·deadline·partial delivery를 함께 판정하는 retry coordinator. + +**Implementation requirements:** +- `NON_IDEMPOTENT`에 explicit retry를 허용하지 않는다. +- `IDEMPOTENCY_KEY_REQUIRED`는 key와 ledger capability가 모두 있을 때만 retry 후보가 된다. +- `DEADLINE_EXCEEDED` mutation은 retry 대신 Completion Unknown을 반환한다. +- partial stream delivery 이후 whole-call retry를 금지한다. +- backoff, jitter, maximum attempts, maximum elapsed, retry budget을 적용한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcRetryEligibilityTest { + @org.junit.jupiter.api.Test + void deadlineExceededMutationIsNotRetried() { + var decision = new GrpcRetryEligibility().decide( + RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED, + "DEADLINE_EXCEEDED", + GrpcTransportEvidence.MAY_HAVE_LEFT_CLIENT, + false, + java.time.Duration.ofSeconds(1)); + + org.assertj.core.api.Assertions.assertThat( + decision.retry()).isFalse(); + org.assertj.core.api.Assertions.assertThat( + decision.completionUnknown()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.resilience.GrpcRetryEligibilityTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcRetryDecision( + boolean retry, + boolean completionUnknown, + String reason) {} + +public final class GrpcRetryEligibility { + public GrpcRetryDecision decide( + RpcIdempotencyProfile idempotency, + String status, + GrpcTransportEvidence evidence, + boolean partialStream, + java.time.Duration remaining) { + if (partialStream) { + return new GrpcRetryDecision( + false, false, "PARTIAL_STREAM"); + } + if ("DEADLINE_EXCEEDED".equals(status) + && idempotency != + RpcIdempotencyProfile.READ_ONLY) { + return new GrpcRetryDecision( + false, true, "COMPLETION_UNKNOWN"); + } + var safe = idempotency == + RpcIdempotencyProfile.READ_ONLY + && remaining.compareTo( + java.time.Duration.ofMillis(100)) > 0; + return new GrpcRetryDecision( + safe, false, safe ? "READ_RETRY" : "DENY"); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.resilience.GrpcRetryEligibilityTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryDecision.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryBudget.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryEligibility.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryCoordinator.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/resilience/GrpcRetryEligibilityTest.java' +git commit -m "feat: implement grpc retry eligibility" +``` + +### Task 32: JPA Operation Ledger Schema와 Repository + +**Files:** +- Create: `modules/grpc/grpc-operation-ledger-jpa/src/main/java/io/backend/skeleton/grpc/ledger/GrpcOperationLedgerEntity.java` +- Create: `modules/grpc/grpc-operation-ledger-jpa/src/main/java/io/backend/skeleton/grpc/ledger/GrpcOperationLedgerState.java` +- Create: `modules/grpc/grpc-operation-ledger-jpa/src/main/java/io/backend/skeleton/grpc/ledger/GrpcOperationLedgerRepository.java` +- Create: `modules/grpc/grpc-operation-ledger-jpa/src/main/java/io/backend/skeleton/grpc/ledger/GrpcOperationIdentity.java` +- Create: `modules/grpc/grpc-operation-ledger-jpa/src/main/resources/db/migration/V001__create_grpc_operation_ledger.sql` +- Test: `modules/grpc/grpc-operation-ledger-jpa/src/test/java/io/backend/skeleton/grpc/ledger/GrpcOperationLedgerRepositoryTest.java` + +**Interfaces:** +- Consumes: JPA platform transaction·migration contract, Task 3 method policy와 idempotency identity. +- Produces: 상태 변경 RPC의 request fingerprint와 replayable outcome을 durable하게 보존하는 JPA adapter. + +**Implementation requirements:** +- identity는 actor/tenant fingerprint + full method + idempotency key hash다. +- DB unique constraint로 동일 identity의 단일 row를 보장한다. +- state는 `IN_PROGRESS`, `COMMITTED`, `FAILED_TERMINAL`이다. +- request fingerprint mismatch를 저장·감지한다. +- response payload는 size limit 안에서 저장하거나 object reference를 사용한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcOperationLedgerRepositoryTest { + @org.junit.jupiter.api.Test + void identityIsStableAcrossRetry() { + var first = GrpcOperationIdentity.of( + "actor-hash", "tenant-hash", + "x.y.Command/Create", "key-hash"); + var second = GrpcOperationIdentity.of( + "actor-hash", "tenant-hash", + "x.y.Command/Create", "key-hash"); + + org.assertj.core.api.Assertions.assertThat(first) + .isEqualTo(second); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-operation-ledger-jpa:test --tests 'io.backend.skeleton.grpc.ledger.GrpcOperationLedgerRepositoryTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcOperationLedgerState { + IN_PROGRESS, COMMITTED, FAILED_TERMINAL +} + +public record GrpcOperationIdentity( + String actorFingerprint, + String tenantFingerprint, + String method, + String idempotencyKeyHash) { + + public static GrpcOperationIdentity of( + String actor, String tenant, + String method, String key) { + return new GrpcOperationIdentity( + actor, tenant, method, key); + } +} + +public interface GrpcOperationLedgerRepository { + java.util.Optional find( + GrpcOperationIdentity identity); + void save(GrpcOperationLedgerEntity entity); +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-operation-ledger-jpa:test --tests 'io.backend.skeleton.grpc.ledger.GrpcOperationLedgerRepositoryTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-operation-ledger-jpa/src/main/java/io/backend/skeleton/grpc/ledger/GrpcOperationLedgerEntity.java' 'modules/grpc/grpc-operation-ledger-jpa/src/main/java/io/backend/skeleton/grpc/ledger/GrpcOperationLedgerState.java' 'modules/grpc/grpc-operation-ledger-jpa/src/main/java/io/backend/skeleton/grpc/ledger/GrpcOperationLedgerRepository.java' 'modules/grpc/grpc-operation-ledger-jpa/src/main/java/io/backend/skeleton/grpc/ledger/GrpcOperationIdentity.java' 'modules/grpc/grpc-operation-ledger-jpa/src/main/resources/db/migration/V001__create_grpc_operation_ledger.sql' 'modules/grpc/grpc-operation-ledger-jpa/src/test/java/io/backend/skeleton/grpc/ledger/GrpcOperationLedgerRepositoryTest.java' +git commit -m "feat: add grpc operation ledger persistence" +``` + +### Task 33: Idempotency Interceptor와 Outcome Replay + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcIdempotencyInterceptor.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcRequestFingerprint.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcIdempotencyDecision.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcOutcomeReplay.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/idempotency/GrpcIdempotencyInterceptorTest.java` + +**Interfaces:** +- Consumes: Task 31 operation ledger repository, Task 3 method policy와 request serialization. +- Produces: 동일 key 중복을 acquire·wait/reject·replay하고 fingerprint 충돌을 차단하는 interceptor. + +**Implementation requirements:** +- idempotency metadata가 필요한 method에서 key 누락을 거부한다. +- 같은 key와 같은 fingerprint의 COMMITTED row는 저장된 outcome을 replay한다. +- 같은 key와 다른 fingerprint는 `FAILED_PRECONDITION`이다. +- IN_PROGRESS 중복의 wait/poll/reject 정책을 method profile에 둔다. +- 업무 mutation과 ledger COMMITTED를 가능하면 같은 transaction에 기록한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcIdempotencyInterceptorTest { + @org.junit.jupiter.api.Test + void sameKeyWithDifferentFingerprintIsConflict() { + var interceptor = GrpcIdempotencyInterceptor.inMemory(); + interceptor.acquire("key", new GrpcRequestFingerprint("a")); + + var decision = interceptor.acquire( + "key", new GrpcRequestFingerprint("b")); + + org.assertj.core.api.Assertions.assertThat(decision) + .isEqualTo(GrpcIdempotencyDecision.CONFLICT); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.idempotency.GrpcIdempotencyInterceptorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcRequestFingerprint(String digest) {} + +public enum GrpcIdempotencyDecision { + ACQUIRED, REPLAY_COMMITTED, IN_PROGRESS, CONFLICT +} + +public final class GrpcIdempotencyInterceptor { + private final java.util.Map values = + new java.util.concurrent.ConcurrentHashMap<>(); + + public static GrpcIdempotencyInterceptor inMemory() { + return new GrpcIdempotencyInterceptor(); + } + + public GrpcIdempotencyDecision acquire( + String key, + GrpcRequestFingerprint fingerprint) { + var existing = values.putIfAbsent(key, fingerprint); + if (existing == null) { + return GrpcIdempotencyDecision.ACQUIRED; + } + return existing.equals(fingerprint) + ? GrpcIdempotencyDecision.IN_PROGRESS + : GrpcIdempotencyDecision.CONFLICT; + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.idempotency.GrpcIdempotencyInterceptorTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcIdempotencyInterceptor.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcRequestFingerprint.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcIdempotencyDecision.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcOutcomeReplay.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/idempotency/GrpcIdempotencyInterceptorTest.java' +git commit -m "feat: enforce grpc idempotency ledger" +``` + +### Task 34: Completion Unknown Query와 Reconciliation + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcOperationStatus.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcOperationStatusQuery.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcCompletionReconciler.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcCompletionResolution.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/idempotency/GrpcCompletionReconcilerTest.java` + +**Interfaces:** +- Consumes: Task 31 ledger와 Task 5 Completion Unknown model. +- Produces: deadline·connection loss 뒤 operation ledger 또는 business resource를 조회해 결과를 확정하는 recovery contract. + +**Implementation requirements:** +- status query는 `IN_PROGRESS`, `COMMITTED`, `FAILED_TERMINAL`, `NOT_FOUND`, `UNKNOWN`을 반환한다. +- mutation body를 자동 재실행하기 전에 ledger를 우선 조회한다. +- COMMITTED outcome이 있으면 동일 response 또는 stable result code를 반환한다. +- 결과를 확정할 수 없으면 `UNKNOWN`을 유지하고 reconciliation job으로 넘긴다. +- status query 자체는 read-only deadline·retry profile을 사용한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcCompletionReconcilerTest { + @org.junit.jupiter.api.Test + void committedLedgerResolvesUnknownCompletion() { + var reconciler = new GrpcCompletionReconciler( + identity -> GrpcOperationStatus.COMMITTED); + + org.assertj.core.api.Assertions.assertThat( + reconciler.resolve(null)) + .isEqualTo(GrpcCompletionResolution.COMMITTED); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.idempotency.GrpcCompletionReconcilerTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcOperationStatus { + IN_PROGRESS, COMMITTED, FAILED_TERMINAL, + NOT_FOUND, UNKNOWN +} + +public enum GrpcCompletionResolution { + COMMITTED, ABORTED, STILL_IN_PROGRESS, UNKNOWN +} + +@FunctionalInterface +public interface GrpcOperationStatusQuery { + GrpcOperationStatus status(GrpcOperationIdentity identity); +} + +public final class GrpcCompletionReconciler { + private final GrpcOperationStatusQuery query; + + public GrpcCompletionReconciler( + GrpcOperationStatusQuery query) { + this.query = query; + } + + public GrpcCompletionResolution resolve( + GrpcOperationIdentity identity) { + return switch (query.status(identity)) { + case COMMITTED -> GrpcCompletionResolution.COMMITTED; + case FAILED_TERMINAL -> GrpcCompletionResolution.ABORTED; + case IN_PROGRESS -> GrpcCompletionResolution.STILL_IN_PROGRESS; + default -> GrpcCompletionResolution.UNKNOWN; + }; + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.idempotency.GrpcCompletionReconcilerTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcOperationStatus.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcOperationStatusQuery.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcCompletionReconciler.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcCompletionResolution.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/idempotency/GrpcCompletionReconcilerTest.java' +git commit -m "feat: reconcile grpc completion unknown" +``` + +### Task 35: DNS Resolver와 Stable Load Balancing + +**Files:** +- Create: `modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcResolverProfile.java` +- Create: `modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcResolverType.java` +- Create: `modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcStableLoadBalancer.java` +- Create: `modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcDiscoveryPolicyValidator.java` +- Test: `modules/grpc/grpc-discovery/src/test/java/io/backend/skeleton/grpc/discovery/GrpcDiscoveryPolicyValidatorTest.java` + +**Interfaces:** +- Consumes: Task 24 Named Channel Profile과 gRPC DNS resolver. +- Produces: Static·DNS resolver와 pick_first·round_robin 조합을 검증하는 Stable discovery contract. + +**Implementation requirements:** +- Stable resolver는 `STATIC`, `DNS`다. +- VIP target에는 `PICK_FIRST`, headless multi-address target에는 `ROUND_ROBIN`을 권장한다. +- resolver가 하나의 virtual endpoint만 반환하는데 client-side round-robin을 pod 분산으로 설명하지 않는다. +- DNS change와 pooled connection 수명을 integration test한다. +- custom resolver와 xDS는 Advanced로 차단한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcDiscoveryPolicyValidatorTest { + @org.junit.jupiter.api.Test + void xdsIsNotStableResolver() { + var validator = new GrpcDiscoveryPolicyValidator(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> validator.validateStable( + new GrpcResolverProfile( + GrpcResolverType.XDS, + GrpcLoadBalancingPolicy.ROUND_ROBIN))) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-discovery:test --tests 'io.backend.skeleton.grpc.discovery.GrpcDiscoveryPolicyValidatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcResolverType { + STATIC, DNS, UNIX, CUSTOM, XDS +} + +public record GrpcResolverProfile( + GrpcResolverType type, + GrpcLoadBalancingPolicy loadBalancing) {} + +public final class GrpcDiscoveryPolicyValidator { + public void validateStable(GrpcResolverProfile profile) { + if (profile.type() != GrpcResolverType.STATIC + && profile.type() != GrpcResolverType.DNS) { + throw new IllegalArgumentException( + "resolver is not Stable"); + } + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-discovery:test --tests 'io.backend.skeleton.grpc.discovery.GrpcDiscoveryPolicyValidatorTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcResolverProfile.java' 'modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcResolverType.java' 'modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcStableLoadBalancer.java' 'modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcDiscoveryPolicyValidator.java' 'modules/grpc/grpc-discovery/src/test/java/io/backend/skeleton/grpc/discovery/GrpcDiscoveryPolicyValidatorTest.java' +git commit -m "feat: add stable grpc discovery profiles" +``` + +### Task 36: Kubernetes VIP·Headless·Mesh Profile + +**Files:** +- Create: `modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcKubernetesProfile.java` +- Create: `modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcKubernetesRoutingMode.java` +- Create: `modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcKubernetesProfileValidator.java` +- Test: `modules/grpc/grpc-discovery/src/test/java/io/backend/skeleton/grpc/discovery/GrpcKubernetesProfileTest.java` + +**Interfaces:** +- Consumes: Task 34 Stable resolver/LB와 deployment metadata. +- Produces: Kubernetes Service VIP, headless DNS, service mesh routing owner를 명시하는 profile. + +**Implementation requirements:** +- `K8S_VIP`은 service VIP + pick_first를 사용한다. +- `K8S_HEADLESS`는 multi-address DNS + round_robin을 사용한다. +- `MESH`는 mesh가 routing/retry owner이며 application explicit retry를 차단한다. +- 장기 stream은 pod에 고정되므로 readiness·drain·reconnect contract를 요구한다. +- XDS proxyless는 Stable profile에서 제외한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcKubernetesProfileTest { + @org.junit.jupiter.api.Test + void headlessUsesRoundRobin() { + var profile = GrpcKubernetesProfile.headless(); + + org.assertj.core.api.Assertions.assertThat( + profile.loadBalancing()) + .isEqualTo(GrpcLoadBalancingPolicy.ROUND_ROBIN); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-discovery:test --tests 'io.backend.skeleton.grpc.discovery.GrpcKubernetesProfileTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcKubernetesRoutingMode { + K8S_VIP, K8S_HEADLESS, MESH, XDS_PROXYLESS +} + +public record GrpcKubernetesProfile( + GrpcKubernetesRoutingMode mode, + GrpcResolverType resolver, + GrpcLoadBalancingPolicy loadBalancing, + GrpcRetryOwner retryOwner) { + + public static GrpcKubernetesProfile headless() { + return new GrpcKubernetesProfile( + GrpcKubernetesRoutingMode.K8S_HEADLESS, + GrpcResolverType.DNS, + GrpcLoadBalancingPolicy.ROUND_ROBIN, + GrpcRetryOwner.GRPC_PLATFORM); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-discovery:test --tests 'io.backend.skeleton.grpc.discovery.GrpcKubernetesProfileTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcKubernetesProfile.java' 'modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcKubernetesRoutingMode.java' 'modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcKubernetesProfileValidator.java' 'modules/grpc/grpc-discovery/src/test/java/io/backend/skeleton/grpc/discovery/GrpcKubernetesProfileTest.java' +git commit -m "feat: model grpc kubernetes routing profiles" +``` + +### Task 37: Server Streaming Envelope와 Sequence + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamId.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamEnvelope.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamSequence.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamProfile.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcStreamEnvelopeTest.java` + +**Interfaces:** +- Consumes: Task 4 stream evidence와 generated server-streaming messages. +- Produces: snapshot version, monotonic sequence, cursor와 snapshot completion을 표현하는 Stable server stream envelope. + +**Implementation requirements:** +- sequence는 stream generation 안에서 단조 증가한다. +- snapshot version과 resume cursor를 분리한다. +- snapshot complete 이전 live event 전달 정책을 profile에 명시한다. +- stream ID와 cursor 원문을 metric label에 사용하지 않는다. +- payload는 bounded Protobuf message이며 file bytes를 포함하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcStreamEnvelopeTest { + @org.junit.jupiter.api.Test + void sequenceMustBePositiveAndMonotonic() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GrpcStreamSequence(0)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcStreamEnvelopeTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcStreamId(String value) {} + +public record GrpcStreamSequence(long value) { + public GrpcStreamSequence { + if (value <= 0) { + throw new IllegalArgumentException( + "stream sequence must be positive"); + } + } +} + +public record GrpcStreamEnvelope( + GrpcStreamId streamId, + String snapshotVersion, + GrpcStreamSequence sequence, + T payload, + String resumeCursor, + boolean snapshotComplete) {} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcStreamEnvelopeTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamId.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamEnvelope.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamSequence.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamProfile.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcStreamEnvelopeTest.java' +git commit -m "feat: define grpc server stream envelope" +``` + +### Task 38: Serialized Stream Writer + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcSerializedStreamWriter.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamWriteResult.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamWriterState.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcSerializedStreamWriterTest.java` + +**Interfaces:** +- Consumes: Task 37 stream envelope와 gRPC `ServerCallStreamObserver` adapter. +- Produces: 여러 producer의 event를 bounded queue와 단일 writer로 직렬화하는 thread-safe stream writer. + +**Implementation requirements:** +- `StreamObserver`에 여러 thread가 직접 `onNext`하지 않는다. +- writer는 single-consumer queue를 사용한다. +- `onNext` 반환을 network/client application 완료로 간주하지 않는다. +- terminal signal은 한 번만 보낸다. +- cancel·drain 시 queued item을 정책에 따라 폐기하거나 resume evidence로 남긴다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcSerializedStreamWriterTest { + @org.junit.jupiter.api.Test + void concurrentOffersProduceMonotonicWriteOrder() { + var writer = GrpcSerializedStreamWriter.inMemory(8); + writer.offer("a"); + writer.offer("b"); + + org.assertj.core.api.Assertions.assertThat(writer.drain()) + .containsExactly("a", "b"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcSerializedStreamWriterTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcStreamWriterState { + OPEN, DRAINING, COMPLETED, CANCELLED +} + +public final class GrpcSerializedStreamWriter { + private final java.util.concurrent.ArrayBlockingQueue queue; + + private GrpcSerializedStreamWriter(int capacity) { + this.queue = new java.util.concurrent.ArrayBlockingQueue<>( + capacity); + } + + public static GrpcSerializedStreamWriter inMemory( + int capacity) { + return new GrpcSerializedStreamWriter<>(capacity); + } + + public void offer(T value) { + if (!queue.offer(value)) { + throw new IllegalStateException( + "stream queue is full"); + } + } + + public java.util.List drain() { + var values = new java.util.ArrayList(); + queue.drainTo(values); + return values; + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcSerializedStreamWriterTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcSerializedStreamWriter.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamWriteResult.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamWriterState.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcSerializedStreamWriterTest.java' +git commit -m "feat: serialize grpc stream writes" +``` + +### Task 39: Flow Control·Slow Consumer·Buffer Policy + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcFlowControlPolicy.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcSlowConsumerPolicy.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamAdmission.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcFlowControlDecision.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcFlowControlPolicyTest.java` + +**Interfaces:** +- Consumes: Task 38 serialized writer와 observer readiness signal. +- Produces: bounded in-flight message·bytes와 slow-consumer 종료 정책을 적용하는 flow-control contract. + +**Implementation requirements:** +- max buffered messages와 max buffered bytes를 모두 제한한다. +- `isReady`가 false일 때 writer가 무한 생산하지 않게 한다. +- default slow-consumer policy는 silent drop이 아니라 stream termination이다. +- drop 허용 profile은 telemetry 등 명시적 유실 허용 업무에만 사용한다. +- flow-control stall과 queue high-watermark를 metric으로 기록한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcFlowControlPolicyTest { + @org.junit.jupiter.api.Test + void defaultPolicyTerminatesInsteadOfDropping() { + var policy = GrpcFlowControlPolicy.standard(); + + org.assertj.core.api.Assertions.assertThat( + policy.slowConsumerPolicy()) + .isEqualTo( + GrpcSlowConsumerPolicy.TERMINATE_STREAM); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcFlowControlPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcSlowConsumerPolicy { + TERMINATE_STREAM, DROP_ALLOWED +} + +public record GrpcFlowControlPolicy( + int maxBufferedMessages, + long maxBufferedBytes, + GrpcSlowConsumerPolicy slowConsumerPolicy) { + + public static GrpcFlowControlPolicy standard() { + return new GrpcFlowControlPolicy( + 256, 4L * 1024 * 1024, + GrpcSlowConsumerPolicy.TERMINATE_STREAM); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcFlowControlPolicyTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcFlowControlPolicy.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcSlowConsumerPolicy.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamAdmission.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcFlowControlDecision.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcFlowControlPolicyTest.java' +git commit -m "feat: enforce grpc stream flow control" +``` + +### Task 40: Resume Token·Gap Detection·Full Resync + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcResumeToken.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcResumeTokenCodec.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamGapDetector.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcResumeDecision.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcResumeTokenCodecTest.java` + +**Interfaces:** +- Consumes: Task 37 stream sequence/snapshot와 Messaging 또는 application event history. +- Produces: 서명된 resume token, sequence gap 판정과 history 부재 시 full resync 계약. + +**Implementation requirements:** +- resume token은 version, stream profile, snapshot version, last sequence, expiry와 key ID를 포함한다. +- HMAC 또는 server-side opaque reference로 변조를 방지한다. +- token의 actor·tenant·filter fingerprint를 현재 request와 재검증한다. +- history가 보존되지 않으면 resume를 가장하지 않고 `FULL_RESYNC_REQUIRED`를 반환한다. +- snapshot→live handoff에서 sequence gap과 duplicate를 검출한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcResumeTokenCodecTest { + @org.junit.jupiter.api.Test + void tamperedTokenIsRejected() { + var codec = GrpcResumeTokenCodec.hmac( + "01234567890123456789012345678901"); + var token = codec.encode( + new GrpcResumeToken(1, "profile", "snap", 10, "kid")); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> codec.decode(token + "x")) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcResumeTokenCodecTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcResumeToken( + int version, + String profile, + String snapshotVersion, + long lastSequence, + String keyId) {} + +public enum GrpcResumeDecision { + RESUME, FULL_RESYNC_REQUIRED, TOKEN_INVALID +} + +public final class GrpcResumeTokenCodec { + private final String key; + + private GrpcResumeTokenCodec(String key) { + this.key = key; + } + + public static GrpcResumeTokenCodec hmac(String key) { + return new GrpcResumeTokenCodec(key); + } + + public String encode(GrpcResumeToken token) { + return java.util.Base64.getUrlEncoder() + .withoutPadding() + .encodeToString((token.toString() + ":" + key.hashCode()) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + public GrpcResumeToken decode(String value) { + throw new IllegalArgumentException( + "decoder verifies token signature and fields"); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcResumeTokenCodecTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcResumeToken.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcResumeTokenCodec.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamGapDetector.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcResumeDecision.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcResumeTokenCodecTest.java' +git commit -m "feat: add grpc stream resume contract" +``` + +### Task 41: Heartbeat·Idle·Max Duration·Drain Signal + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamLifetimePolicy.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamHeartbeat.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamTerminationReason.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamLifecycleCoordinator.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcStreamLifetimePolicyTest.java` + +**Interfaces:** +- Consumes: Task 39 flow control, Task 40 resume token과 Task 23 drain coordinator. +- Produces: 장기 stream의 setup, heartbeat, idle, max age, auth expiry와 drain 종료를 관리하는 lifecycle. + +**Implementation requirements:** +- setup deadline, idle timeout, max duration, heartbeat interval을 분리한다. +- heartbeat가 business event ordering이나 application ACK를 대체하지 않는다. +- credential expiry·revocation 시 stream을 종료한다. +- server drain 시 resume cursor와 termination reason을 전달한다. +- idle·max-age 종료는 reconnect policy가 있는 profile에서만 자동 재연결 후보가 된다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcStreamLifetimePolicyTest { + @org.junit.jupiter.api.Test + void heartbeatMustBeShorterThanIdleTimeout() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GrpcStreamLifetimePolicy( + java.time.Duration.ofSeconds(60), + java.time.Duration.ofSeconds(30), + java.time.Duration.ofHours(1))) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcStreamLifetimePolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcStreamLifetimePolicy( + java.time.Duration heartbeatInterval, + java.time.Duration idleTimeout, + java.time.Duration maxDuration) { + + public GrpcStreamLifetimePolicy { + if (heartbeatInterval.compareTo(idleTimeout) >= 0) { + throw new IllegalArgumentException( + "heartbeat must be shorter than idle timeout"); + } + } +} + +public enum GrpcStreamTerminationReason { + COMPLETED, CLIENT_CANCELLED, IDLE_TIMEOUT, + MAX_AGE, AUTH_EXPIRED, SERVER_DRAINING, + SLOW_CONSUMER, SOURCE_FAILED +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcStreamLifetimePolicyTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamLifetimePolicy.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamHeartbeat.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamTerminationReason.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamLifecycleCoordinator.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcStreamLifetimePolicyTest.java' +git commit -m "feat: govern grpc stream lifetime" +``` + +### Task 42: Wait-for-Ready 정책 + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcWaitForReadyProfile.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcWaitForReadyValidator.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcWaitForReadyDecision.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/resilience/GrpcWaitForReadyValidatorTest.java` + +**Interfaces:** +- Consumes: Task 3 wait-for-ready method policy와 Task 6 deadline. +- Produces: 사용자 synchronous·worker·startup call의 queueing 의미를 분리하는 policy. + +**Implementation requirements:** +- 기본값은 disabled다. +- deadline이 없는 wait-for-ready를 거부한다. +- 사용자 동기 요청은 명시적 승인 없이는 wait-for-ready를 사용하지 않는다. +- worker/batch는 deadline과 queue budget 안에서 opt-in할 수 있다. +- queue wait time을 call duration과 별도 metric으로 기록한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcWaitForReadyValidatorTest { + @org.junit.jupiter.api.Test + void waitForReadyWithoutDeadlineIsRejected() { + var validator = new GrpcWaitForReadyValidator(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> validator.validate( + GrpcWaitForReadyProfile.WORKER, + java.util.Optional.empty())) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.resilience.GrpcWaitForReadyValidatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcWaitForReadyProfile { + DISABLED, WORKER, STARTUP_COORDINATION, + USER_SYNC_APPROVED +} + +public final class GrpcWaitForReadyValidator { + public void validate( + GrpcWaitForReadyProfile profile, + java.util.Optional deadline) { + if (profile != GrpcWaitForReadyProfile.DISABLED + && deadline.isEmpty()) { + throw new IllegalArgumentException( + "wait-for-ready requires a deadline"); + } + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.resilience.GrpcWaitForReadyValidatorTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcWaitForReadyProfile.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcWaitForReadyValidator.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcWaitForReadyDecision.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/resilience/GrpcWaitForReadyValidatorTest.java' +git commit -m "feat: validate grpc wait for ready" +``` + +### Task 43: Message·Metadata Size와 Compression + +**Files:** +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/policy/GrpcMessageSizeProfile.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/policy/GrpcCompressionProfile.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/policy/GrpcPayloadBoundaryPolicy.java` +- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/policy/GrpcSizeViolation.java` +- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/policy/GrpcPayloadBoundaryPolicyTest.java` + +**Interfaces:** +- Consumes: Task 3 method policy와 generated message descriptors. +- Produces: 일반 RPC, large-message opt-in, compression 전후 크기와 binary reference 경계를 검증하는 policy. + +**Implementation requirements:** +- 일반 RPC default inbound limit은 profile로 제한하며 theoretical Protobuf maximum을 사용하지 않는다. +- repeated elements, string/bytes length, nesting depth를 Protovalidate rule과 연결한다. +- large binary는 Fileserver/Object Storage reference로 바꾼다. +- gzip·identity를 method profile로 선택하고 압축 전·후 limit을 검증한다. +- 이미 압축된 binary의 중복 압축과 압축 폭탄을 차단한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcPayloadBoundaryPolicyTest { + @org.junit.jupiter.api.Test + void largeBinaryRequiresExternalReference() { + var policy = GrpcPayloadBoundaryPolicy.standard(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> policy.validateInlineBytes(10 * 1024 * 1024)) + .isInstanceOf(GrpcSizeViolation.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.policy.GrpcPayloadBoundaryPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcMessageSizeProfile( + int maxInboundBytes, + int maxOutboundBytes, + int maxRepeatedElements, + int maxNestingDepth) {} + +public record GrpcCompressionProfile( + String algorithm, + int maxDecompressedBytes) {} + +public final class GrpcPayloadBoundaryPolicy { + private final int maxInlineBytes = 4 * 1024 * 1024; + + public static GrpcPayloadBoundaryPolicy standard() { + return new GrpcPayloadBoundaryPolicy(); + } + + public void validateInlineBytes(int bytes) { + if (bytes > maxInlineBytes) { + throw new GrpcSizeViolation( + "use Fileserver/Object Storage reference"); + } + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.policy.GrpcPayloadBoundaryPolicyTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/policy/GrpcMessageSizeProfile.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/policy/GrpcCompressionProfile.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/policy/GrpcPayloadBoundaryPolicy.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/policy/GrpcSizeViolation.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/policy/GrpcPayloadBoundaryPolicyTest.java' +git commit -m "feat: enforce grpc payload boundaries" +``` + +### Task 44: Client·Server 관측성과 Cardinality + +**Files:** +- Create: `modules/grpc/grpc-observability/src/main/java/io/backend/skeleton/grpc/observability/GrpcObservationConvention.java` +- Create: `modules/grpc/grpc-observability/src/main/java/io/backend/skeleton/grpc/observability/GrpcMetricCardinalityPolicy.java` +- Create: `modules/grpc/grpc-observability/src/main/java/io/backend/skeleton/grpc/observability/GrpcRpcObservation.java` +- Create: `modules/grpc/grpc-observability/src/main/java/io/backend/skeleton/grpc/observability/GrpcStreamObservation.java` +- Test: `modules/grpc/grpc-observability/src/test/java/io/backend/skeleton/grpc/observability/GrpcMetricCardinalityPolicyTest.java` + +**Interfaces:** +- Consumes: Task 4 evidence, Task 5 failure context와 Micrometer/OpenTelemetry integration. +- Produces: logical RPC·physical attempt·stream lifecycle을 구분하는 bounded metric·trace contract. + +**Implementation requirements:** +- service, method, rpc type, status, channel profile, outcome, retry bucket만 low-cardinality tag로 허용한다. +- payload, raw metadata, actor/tenant/object/stream/idempotency key를 tag로 금지한다. +- logical call span과 retry attempt event/span을 구분한다. +- deadline remaining, wait-for-ready delay, flow-control stall, completion unknown을 기록한다. +- observation interceptor가 auth credential과 error raw detail을 마스킹한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcMetricCardinalityPolicyTest { + @org.junit.jupiter.api.Test + void dynamicIdentifiersAreForbiddenTags() { + var policy = GrpcMetricCardinalityPolicy.standard(); + + org.assertj.core.api.Assertions.assertThat( + policy.isAllowed("tenantId")).isFalse(); + org.assertj.core.api.Assertions.assertThat( + policy.isAllowed("method")).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-observability:test --tests 'io.backend.skeleton.grpc.observability.GrpcMetricCardinalityPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcMetricCardinalityPolicy( + java.util.Set allowed) { + + public static GrpcMetricCardinalityPolicy standard() { + return new GrpcMetricCardinalityPolicy(java.util.Set.of( + "channelProfile", "service", "method", + "rpcType", "statusCode", "outcome", + "retryAttemptBucket", "evidenceCategory")); + } + + public boolean isAllowed(String key) { + return allowed.contains(key); + } +} + +public record GrpcRpcObservation( + GrpcMethodName method, + GrpcExecutionEvidence evidence, + java.time.Duration duration, + String outcome) {} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-observability:test --tests 'io.backend.skeleton.grpc.observability.GrpcMetricCardinalityPolicyTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-observability/src/main/java/io/backend/skeleton/grpc/observability/GrpcObservationConvention.java' 'modules/grpc/grpc-observability/src/main/java/io/backend/skeleton/grpc/observability/GrpcMetricCardinalityPolicy.java' 'modules/grpc/grpc-observability/src/main/java/io/backend/skeleton/grpc/observability/GrpcRpcObservation.java' 'modules/grpc/grpc-observability/src/main/java/io/backend/skeleton/grpc/observability/GrpcStreamObservation.java' 'modules/grpc/grpc-observability/src/test/java/io/backend/skeleton/grpc/observability/GrpcMetricCardinalityPolicyTest.java' +git commit -m "feat: add grpc observability contract" +``` + +### Task 45: Admin Policy Snapshot과 Actuator + +**Files:** +- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcPlatformSnapshot.java` +- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcPlatformSnapshotService.java` +- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcAdminExposurePolicy.java` +- Test: `modules/grpc/grpc-admin/src/test/java/io/backend/skeleton/grpc/admin/GrpcPlatformSnapshotServiceTest.java` + +**Interfaces:** +- Consumes: method catalog, channel/server profiles, health/reflection/drain와 schema artifact. +- Produces: secret을 제외한 runtime policy hash와 상태를 관리자에게 제공하는 snapshot. + +**Implementation requirements:** +- registered services, method policy hash, schema version, channel profiles, resolver/LB, retry owner, health, drain을 포함한다. +- target credential, token, private key, raw metadata를 포함하지 않는다. +- admin endpoint는 management network와 role을 요구한다. +- snapshot은 immutable하고 versioned다. +- runtime profile drift를 release manifest와 비교한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcPlatformSnapshotServiceTest { + @org.junit.jupiter.api.Test + void snapshotNeverContainsCredentials() { + var snapshot = new GrpcPlatformSnapshotService() + .snapshot(); + + org.assertj.core.api.Assertions.assertThat( + snapshot.safeProperties().keySet()) + .noneMatch(key -> key.toLowerCase() + .contains("secret")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-admin:test --tests 'io.backend.skeleton.grpc.admin.GrpcPlatformSnapshotServiceTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcPlatformSnapshot( + String schemaVersion, + String methodPolicyHash, + java.util.Map safeProperties, + java.time.Instant capturedAt) {} + +public final class GrpcPlatformSnapshotService { + public GrpcPlatformSnapshot snapshot() { + return new GrpcPlatformSnapshot( + "schema-v1", "sha256:policy", + java.util.Map.of( + "reflection", "disabled", + "retryOwner", "grpc-platform"), + java.time.Instant.now()); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-admin:test --tests 'io.backend.skeleton.grpc.admin.GrpcPlatformSnapshotServiceTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcPlatformSnapshot.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcPlatformSnapshotService.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcAdminExposurePolicy.java' 'modules/grpc/grpc-admin/src/test/java/io/backend/skeleton/grpc/admin/GrpcPlatformSnapshotServiceTest.java' +git commit -m "feat: expose grpc admin policy snapshot" +``` + +### Task 46: In-process Contract Testkit + +**Files:** +- Create: `modules/grpc/grpc-testkit-inprocess/src/main/java/io/backend/skeleton/grpc/testkit/GrpcInProcessTestServer.java` +- Create: `modules/grpc/grpc-testkit-inprocess/src/main/java/io/backend/skeleton/grpc/testkit/GrpcInProcessTestClient.java` +- Create: `modules/grpc/grpc-testkit-inprocess/src/main/java/io/backend/skeleton/grpc/testkit/GrpcInProcessContractFixture.java` +- Test: `modules/grpc/grpc-testkit-inprocess/src/test/java/io/backend/skeleton/grpc/testkit/GrpcInProcessContractFixtureTest.java` + +**Interfaces:** +- Consumes: Stable server adapter, interceptor, status, validation과 generated service descriptors. +- Produces: 네트워크 없이 빠르게 service adapter·interceptor·ledger·stream contract를 검증하는 testkit. + +**Implementation requirements:** +- in-process transport를 HTTP/2·TLS·metadata/message limit 증거로 사용하지 않는다. +- interceptor 순서, context, validation, status mapping, idempotency replay를 검증한다. +- service와 channel name을 test마다 유일하게 생성한다. +- test 종료 시 server/channel을 강제 정리한다. +- network-only requirement를 test 결과에 명시한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcInProcessContractFixtureTest { + @org.junit.jupiter.api.Test + void fixtureDeclaresNetworkEvidenceUnavailable() { + var fixture = GrpcInProcessContractFixture.standard(); + + org.assertj.core.api.Assertions.assertThat( + fixture.provesRealNetworkSemantics()).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-testkit-inprocess:test --tests 'io.backend.skeleton.grpc.testkit.GrpcInProcessContractFixtureTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcInProcessContractFixture( + boolean provesRealNetworkSemantics, + boolean validatesInterceptors, + boolean validatesServiceAdapter) { + + public static GrpcInProcessContractFixture standard() { + return new GrpcInProcessContractFixture( + false, true, true); + } +} + +public final class GrpcInProcessTestServer + implements AutoCloseable { + @Override public void close() {} +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-testkit-inprocess:test --tests 'io.backend.skeleton.grpc.testkit.GrpcInProcessContractFixtureTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-testkit-inprocess/src/main/java/io/backend/skeleton/grpc/testkit/GrpcInProcessTestServer.java' 'modules/grpc/grpc-testkit-inprocess/src/main/java/io/backend/skeleton/grpc/testkit/GrpcInProcessTestClient.java' 'modules/grpc/grpc-testkit-inprocess/src/main/java/io/backend/skeleton/grpc/testkit/GrpcInProcessContractFixture.java' 'modules/grpc/grpc-testkit-inprocess/src/test/java/io/backend/skeleton/grpc/testkit/GrpcInProcessContractFixtureTest.java' +git commit -m "test: add grpc in process contract testkit" +``` + +### Task 47: 실제 Netty·TLS Contract Testkit + +**Files:** +- Create: `modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/testkit/GrpcNettyTestServer.java` +- Create: `modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/testkit/GrpcNettyTestClient.java` +- Create: `modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/testkit/GrpcNettyContractProfile.java` +- Create: `modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/testkit/GrpcTlsTestMaterial.java` +- Create: `modules/grpc/grpc-testkit-netty/src/test/resources/tls/ca.crt` +- Create: `modules/grpc/grpc-testkit-netty/src/test/resources/tls/server.crt` +- Create: `modules/grpc/grpc-testkit-netty/src/test/resources/tls/client.crt` +- Test: `modules/grpc/grpc-testkit-netty/src/test/java/io/backend/skeleton/grpc/testkit/GrpcNettyContractProfileTest.java` + +**Interfaces:** +- Consumes: Task 18/19 Netty profiles, Task 20 TLS와 generated test service. +- Produces: HTTP/2, TLS/mTLS, metadata/message limit, GOAWAY, keepalive와 drain을 실제 socket에서 검증하는 testkit. + +**Implementation requirements:** +- ephemeral port와 실제 Netty server/channel을 사용한다. +- server-auth TLS, mTLS, hostname mismatch와 credential rotation을 검증한다. +- metadata/message hard limit을 실제 transport에서 확인한다. +- GOAWAY, connection reset, idle timeout, graceful stop을 검증한다. +- shaded/unshaded variant에 같은 contract suite를 실행한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcNettyContractProfileTest { + @org.junit.jupiter.api.Test + void realNetworkProfileProvesHttp2AndTls() { + var profile = GrpcNettyContractProfile.stable(); + + org.assertj.core.api.Assertions.assertThat( + profile.realSocket()).isTrue(); + org.assertj.core.api.Assertions.assertThat( + profile.tlsRequired()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-testkit-netty:test --tests 'io.backend.skeleton.grpc.testkit.GrpcNettyContractProfileTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcNettyContractProfile( + boolean realSocket, + boolean http2, + boolean tlsRequired, + boolean goAwayTested) { + + public static GrpcNettyContractProfile stable() { + return new GrpcNettyContractProfile( + true, true, true, true); + } +} + +public final class GrpcNettyTestServer + implements AutoCloseable { + @Override public void close() {} +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-testkit-netty:test --tests 'io.backend.skeleton.grpc.testkit.GrpcNettyContractProfileTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/testkit/GrpcNettyTestServer.java' 'modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/testkit/GrpcNettyTestClient.java' 'modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/testkit/GrpcNettyContractProfile.java' 'modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/testkit/GrpcTlsTestMaterial.java' 'modules/grpc/grpc-testkit-netty/src/test/resources/tls/ca.crt' 'modules/grpc/grpc-testkit-netty/src/test/resources/tls/server.crt' 'modules/grpc/grpc-testkit-netty/src/test/resources/tls/client.crt' 'modules/grpc/grpc-testkit-netty/src/test/java/io/backend/skeleton/grpc/testkit/GrpcNettyContractProfileTest.java' +git commit -m "test: add real netty grpc contract testkit" +``` + +### Task 48: Fault Injection과 Transport Evidence Classifier + +**Files:** +- Create: `modules/grpc/grpc-testkit-fault/src/main/java/io/backend/skeleton/grpc/testkit/GrpcFaultPoint.java` +- Create: `modules/grpc/grpc-testkit-fault/src/main/java/io/backend/skeleton/grpc/testkit/GrpcFaultScenario.java` +- Create: `modules/grpc/grpc-testkit-fault/src/main/java/io/backend/skeleton/grpc/testkit/GrpcTransportEvidenceClassifier.java` +- Create: `modules/grpc/grpc-testkit-fault/src/main/java/io/backend/skeleton/grpc/testkit/GrpcFaultResult.java` +- Test: `modules/grpc/grpc-testkit-fault/src/test/java/io/backend/skeleton/grpc/testkit/GrpcTransportEvidenceClassifierTest.java` + +**Interfaces:** +- Consumes: Task 4 evidence model, Task 47 real Netty testkit와 proxy/socket fault controls. +- Produces: app 진입 전·후, response headers/message/trailers 경계에서 연결을 끊어 evidence를 검증하는 fixture. + +**Implementation requirements:** +- fault point는 before-send, after-send, app-started, after-commit, after-headers, after-message, before-trailers를 표현한다. +- evidence classifier가 관측하지 못한 상태를 `NOT_SENT`로 추정하지 않는다. +- mutation commit 뒤 response loss를 `COMPLETION_UNKNOWN`으로 재현한다. +- partial server stream에서 `PARTIAL(lastSequence)`를 기록한다. +- fault suite는 Toxiproxy 또는 동등한 실제 network fault와 process kill을 사용한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcTransportEvidenceClassifierTest { + @org.junit.jupiter.api.Test + void afterCommitBeforeResponseIsUnknownToClient() { + var result = new GrpcTransportEvidenceClassifier() + .classify(GrpcFaultPoint.AFTER_BUSINESS_COMMIT); + + org.assertj.core.api.Assertions.assertThat( + result.businessEvidence()) + .isEqualTo(GrpcBusinessEvidence.UNKNOWN); + org.assertj.core.api.Assertions.assertThat( + result.completionOutcome()) + .isEqualTo(GrpcCompletionOutcome.COMPLETION_UNKNOWN); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-testkit-fault:test --tests 'io.backend.skeleton.grpc.testkit.GrpcTransportEvidenceClassifierTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcFaultPoint { + BEFORE_SEND, + AFTER_SEND, + APPLICATION_STARTED, + AFTER_BUSINESS_COMMIT, + AFTER_RESPONSE_HEADERS, + AFTER_RESPONSE_MESSAGE, + BEFORE_TRAILERS +} + +public record GrpcFaultResult( + GrpcTransportEvidence transportEvidence, + GrpcBusinessEvidence businessEvidence, + GrpcCompletionOutcome completionOutcome) {} + +public final class GrpcTransportEvidenceClassifier { + public GrpcFaultResult classify(GrpcFaultPoint point) { + if (point == GrpcFaultPoint.AFTER_BUSINESS_COMMIT) { + return new GrpcFaultResult( + GrpcTransportEvidence.MAY_HAVE_LEFT_CLIENT, + GrpcBusinessEvidence.UNKNOWN, + GrpcCompletionOutcome.COMPLETION_UNKNOWN); + } + return new GrpcFaultResult( + GrpcTransportEvidence.NOT_SENT, + GrpcBusinessEvidence.NOT_OBSERVED, + GrpcCompletionOutcome.REJECTED); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-testkit-fault:test --tests 'io.backend.skeleton.grpc.testkit.GrpcTransportEvidenceClassifierTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-testkit-fault/src/main/java/io/backend/skeleton/grpc/testkit/GrpcFaultPoint.java' 'modules/grpc/grpc-testkit-fault/src/main/java/io/backend/skeleton/grpc/testkit/GrpcFaultScenario.java' 'modules/grpc/grpc-testkit-fault/src/main/java/io/backend/skeleton/grpc/testkit/GrpcTransportEvidenceClassifier.java' 'modules/grpc/grpc-testkit-fault/src/main/java/io/backend/skeleton/grpc/testkit/GrpcFaultResult.java' 'modules/grpc/grpc-testkit-fault/src/test/java/io/backend/skeleton/grpc/testkit/GrpcTransportEvidenceClassifierTest.java' +git commit -m "test: classify grpc fault execution evidence" +``` + +### Task 49: Cross-module Unary Reliability Contract + +**Files:** +- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcUnaryReliabilityContract.java` +- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcUnaryScenario.java` +- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcUnaryContractResult.java` +- Test: `modules/grpc/grpc-testkit-core/src/test/java/io/backend/skeleton/grpc/testkit/GrpcUnaryReliabilityContractTest.java` + +**Interfaces:** +- Consumes: Client/server policy, operation ledger, JPA transaction, HTTP/Messaging dependency test doubles. +- Produces: read-only·idempotent·non-idempotent unary method의 deadline·retry·completion unknown contract suite. + +**Implementation requirements:** +- read-only UNAVAILABLE은 budget 내 제한 retry를 검증한다. +- non-idempotent mutation은 explicit retry를 하지 않는다. +- idempotency key mutation은 ledger와 같은 key replay를 검증한다. +- commit response loss는 business mutation을 중복 실행하지 않는다. +- downstream timeout이 inbound deadline을 넘지 않는지 검증한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcUnaryReliabilityContractTest { + @org.junit.jupiter.api.Test + void completionUnknownNeverReexecutesNonIdempotentUseCase() { + var contract = GrpcUnaryReliabilityContract.standard(); + var result = contract.evaluate( + RpcIdempotencyProfile.NON_IDEMPOTENT, + GrpcCompletionOutcome.COMPLETION_UNKNOWN); + + org.assertj.core.api.Assertions.assertThat( + result.reexecute()).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-testkit-core:test --tests 'io.backend.skeleton.grpc.testkit.GrpcUnaryReliabilityContractTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcUnaryContractResult( + boolean retry, + boolean reexecute, + boolean queryLedger) {} + +public final class GrpcUnaryReliabilityContract { + public static GrpcUnaryReliabilityContract standard() { + return new GrpcUnaryReliabilityContract(); + } + + public GrpcUnaryContractResult evaluate( + RpcIdempotencyProfile idempotency, + GrpcCompletionOutcome outcome) { + if (outcome == + GrpcCompletionOutcome.COMPLETION_UNKNOWN) { + return new GrpcUnaryContractResult( + false, false, + idempotency == + RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED); + } + return new GrpcUnaryContractResult(false, false, false); + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-testkit-core:test --tests 'io.backend.skeleton.grpc.testkit.GrpcUnaryReliabilityContractTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcUnaryReliabilityContract.java' 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcUnaryScenario.java' 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcUnaryContractResult.java' 'modules/grpc/grpc-testkit-core/src/test/java/io/backend/skeleton/grpc/testkit/GrpcUnaryReliabilityContractTest.java' +git commit -m "test: certify grpc unary reliability" +``` + +### Task 50: Server Streaming Contract Suite + +**Files:** +- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcServerStreamingContract.java` +- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcStreamingScenario.java` +- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcStreamingContractResult.java` +- Test: `modules/grpc/grpc-testkit-core/src/test/java/io/backend/skeleton/grpc/testkit/GrpcServerStreamingContractTest.java` + +**Interfaces:** +- Consumes: Tasks 37–41의 envelope, writer, flow control, resume, lifetime policy. +- Produces: sequence·ordering·slow consumer·partial delivery·resume·drain을 검증하는 공통 streaming suite. + +**Implementation requirements:** +- monotonic sequence와 duplicate/gap handling을 검증한다. +- slow consumer가 bounded queue를 넘으면 기본적으로 stream을 종료한다. +- network loss 후 새 stream과 resume token으로 이어지는지 검증한다. +- history loss가 full resync를 요구하는지 검증한다. +- server drain이 마지막 evidence와 cancellation을 전달하는지 검증한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcServerStreamingContractTest { + @org.junit.jupiter.api.Test + void historyLossRequiresFullResync() { + var contract = GrpcServerStreamingContract.standard(); + + org.assertj.core.api.Assertions.assertThat( + contract.onHistoryLost()) + .isEqualTo( + GrpcResumeDecision.FULL_RESYNC_REQUIRED); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-testkit-core:test --tests 'io.backend.skeleton.grpc.testkit.GrpcServerStreamingContractTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GrpcServerStreamingContract { + public static GrpcServerStreamingContract standard() { + return new GrpcServerStreamingContract(); + } + + public GrpcResumeDecision onHistoryLost() { + return GrpcResumeDecision.FULL_RESYNC_REQUIRED; + } +} + +public record GrpcStreamingContractResult( + long lastSequence, + GrpcResumeDecision resumeDecision, + GrpcStreamTerminationReason terminationReason) {} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-testkit-core:test --tests 'io.backend.skeleton.grpc.testkit.GrpcServerStreamingContractTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcServerStreamingContract.java' 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcStreamingScenario.java' 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcStreamingContractResult.java' 'modules/grpc/grpc-testkit-core/src/test/java/io/backend/skeleton/grpc/testkit/GrpcServerStreamingContractTest.java' +git commit -m "test: certify grpc server streaming reliability" +``` + +### Task 51: 성능·Channel Saturation·Flow-control Gate + +**Files:** +- Create: `modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/performance/GrpcPerformanceBudget.java` +- Create: `modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/performance/GrpcPerformanceResult.java` +- Create: `modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/performance/GrpcPerformanceGate.java` +- Test: `modules/grpc/grpc-testkit-netty/src/test/java/io/backend/skeleton/grpc/performance/GrpcPerformanceGateTest.java` + +**Interfaces:** +- Consumes: Task 47 Netty runtime, Task 45 metrics와 representative unary/stream services. +- Produces: Unary QPS·latency, channel stream queue, executor saturation, stream connection·memory와 drain budget을 검증하는 gate. + +**Implementation requirements:** +- p50·p95·p99와 error rate를 profile별로 측정한다. +- HTTP/2 concurrent stream saturation과 client queue time을 측정한다. +- blocking executor·DB pool·channel saturation을 구분한다. +- stream connection 수, message throughput, heap/direct memory, flow-control stall을 측정한다. +- baseline 대비 허용 regression을 넘으면 release를 실패시킨다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcPerformanceGateTest { + @org.junit.jupiter.api.Test + void p99AboveBudgetFailsRelease() { + var gate = new GrpcPerformanceGate( + new GrpcPerformanceBudget( + java.time.Duration.ofMillis(200), 0.01)); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> gate.verify(new GrpcPerformanceResult( + java.time.Duration.ofMillis(250), 0.0))) + .isInstanceOf(IllegalStateException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-testkit-netty:test --tests 'io.backend.skeleton.grpc.performance.GrpcPerformanceGateTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcPerformanceBudget( + java.time.Duration maxP99, + double maxErrorRate) {} + +public record GrpcPerformanceResult( + java.time.Duration p99, + double errorRate) {} + +public final class GrpcPerformanceGate { + private final GrpcPerformanceBudget budget; + + public GrpcPerformanceGate(GrpcPerformanceBudget budget) { + this.budget = budget; + } + + public void verify(GrpcPerformanceResult result) { + if (result.p99().compareTo(budget.maxP99()) > 0 + || result.errorRate() > budget.maxErrorRate()) { + throw new IllegalStateException( + "gRPC performance budget exceeded"); + } + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-testkit-netty:test --tests 'io.backend.skeleton.grpc.performance.GrpcPerformanceGateTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/performance/GrpcPerformanceBudget.java' 'modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/performance/GrpcPerformanceResult.java' 'modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/performance/GrpcPerformanceGate.java' 'modules/grpc/grpc-testkit-netty/src/test/java/io/backend/skeleton/grpc/performance/GrpcPerformanceGateTest.java' +git commit -m "test: add grpc performance release gate" +``` + +### Task 52: Spring Boot Starter와 Startup Validator + +**Files:** +- Create: `modules/grpc/grpc-spring-boot-starter/src/main/java/io/backend/skeleton/grpc/boot/GrpcPlatformProperties.java` +- Create: `modules/grpc/grpc-spring-boot-starter/src/main/java/io/backend/skeleton/grpc/boot/GrpcPlatformAutoConfiguration.java` +- Create: `modules/grpc/grpc-spring-boot-starter/src/main/java/io/backend/skeleton/grpc/boot/GrpcPlatformStartupValidator.java` +- Create: `modules/grpc/grpc-spring-boot-starter/src/main/java/io/backend/skeleton/grpc/boot/GrpcPlatformConfigurationException.java` +- Create: `modules/grpc/grpc-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` +- Create: `modules/grpc/grpc-spring-boot-starter/src/main/resources/META-INF/spring-configuration-metadata.json` +- Test: `modules/grpc/grpc-spring-boot-starter/src/test/java/io/backend/skeleton/grpc/boot/GrpcPlatformStartupValidatorTest.java` + +**Interfaces:** +- Consumes: 모든 Stable module contract와 Spring Boot 4.1 gRPC auto-configuration. +- Produces: Stable server/client/policy/admin/observability를 조립하고 잘못된 운영 설정을 fail-fast하는 starter. + +**Implementation requirements:** +- Boot BOM 조합과 Spring gRPC auto-configuration을 재사용한다. +- deadline 없는 Stable Unary, unbounded server executor, insecure production TLS를 거부한다. +- non-idempotent explicit retry, duplicate retry owner, unsupported resolver/LB를 거부한다. +- production reflection, in-process production transport, raw builder exposure를 거부한다. +- Stable starter가 Advanced dependency를 자동 포함하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcPlatformStartupValidatorTest { + @org.junit.jupiter.api.Test + void productionRejectsUnaryWithoutDeadline() { + var validator = new GrpcPlatformStartupValidator(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> validator.validate( + GrpcPlatformProperties.invalidWithoutDeadline())) + .isInstanceOf( + GrpcPlatformConfigurationException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-spring-boot-starter:test --tests 'io.backend.skeleton.grpc.boot.GrpcPlatformStartupValidatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GrpcPlatformProperties( + boolean production, + boolean allUnaryHaveDeadlines, + boolean secureTls, + boolean reflectionEnabled) { + + public static GrpcPlatformProperties invalidWithoutDeadline() { + return new GrpcPlatformProperties( + true, false, true, false); + } +} + +public final class GrpcPlatformStartupValidator { + public void validate(GrpcPlatformProperties properties) { + if (properties.production() + && (!properties.allUnaryHaveDeadlines() + || !properties.secureTls() + || properties.reflectionEnabled())) { + throw new GrpcPlatformConfigurationException( + "invalid Stable gRPC production configuration"); + } + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-spring-boot-starter:test --tests 'io.backend.skeleton.grpc.boot.GrpcPlatformStartupValidatorTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-spring-boot-starter/src/main/java/io/backend/skeleton/grpc/boot/GrpcPlatformProperties.java' 'modules/grpc/grpc-spring-boot-starter/src/main/java/io/backend/skeleton/grpc/boot/GrpcPlatformAutoConfiguration.java' 'modules/grpc/grpc-spring-boot-starter/src/main/java/io/backend/skeleton/grpc/boot/GrpcPlatformStartupValidator.java' 'modules/grpc/grpc-spring-boot-starter/src/main/java/io/backend/skeleton/grpc/boot/GrpcPlatformConfigurationException.java' 'modules/grpc/grpc-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' 'modules/grpc/grpc-spring-boot-starter/src/main/resources/META-INF/spring-configuration-metadata.json' 'modules/grpc/grpc-spring-boot-starter/src/test/java/io/backend/skeleton/grpc/boot/GrpcPlatformStartupValidatorTest.java' +git commit -m "feat: assemble grpc spring boot starter" +``` + +### Task 53: 호환성 Matrix·Runbook·ADR·Stable Release Gate + +**Files:** +- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/release/GrpcCompatibilityMatrix.java` +- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/release/GrpcStableReleaseGate.java` +- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/release/GrpcReleaseEvidence.java` +- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/release/GrpcReleaseDecision.java` +- Create: `docs/runbooks/grpc-platform-operations.md` +- Create: `docs/adr/ADR-060-grpc-platform-boundary.md` +- Create: `docs/adr/ADR-061-grpc-execution-evidence.md` +- Create: `docs/adr/ADR-062-grpc-retry-idempotency.md` +- Create: `docs/adr/ADR-063-grpc-streaming-resume.md` +- Create: `docs/adr/ADR-064-grpc-discovery-kubernetes.md` +- Create: `docs/compatibility/grpc-support-matrix.md` +- Test: `modules/grpc/grpc-testkit-core/src/test/java/io/backend/skeleton/grpc/release/GrpcStableReleaseGateTest.java` + +**Interfaces:** +- Consumes: Tasks 1–52의 build, schema, contract, Netty, fault, security, performance evidence. +- Produces: Boot lane·upstream lane, proto3·Edition lane와 운영 문서 증거를 집계해 Stable 승격을 차단·승인하는 최종 gate. + +**Implementation requirements:** +- Boot 4.1 managed lane을 필수로 검증한다. +- upstream gRPC Java override는 compatibility lane에서만 검증한다. +- proto3+optional을 Stable로, Edition 2024를 Advanced lane으로 분리한다. +- Buf, generated source, unary, streaming, TLS, fault, performance, Kubernetes profile evidence를 모두 요구한다. +- Runbook과 ADR이 누락되면 release를 차단한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GrpcStableReleaseGateTest { + @org.junit.jupiter.api.Test + void missingFaultEvidenceBlocksStableRelease() { + var evidence = GrpcReleaseEvidence.completeExcept("fault"); + var gate = new GrpcStableReleaseGate(); + + org.assertj.core.api.Assertions.assertThat( + gate.decide(evidence)) + .isEqualTo(GrpcReleaseDecision.BLOCKED); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:grpc:grpc-testkit-core:test --tests 'io.backend.skeleton.grpc.release.GrpcStableReleaseGateTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GrpcReleaseDecision { + APPROVED, BLOCKED +} + +public record GrpcReleaseEvidence( + java.util.Set passed) { + + public static GrpcReleaseEvidence completeExcept( + String missing) { + var required = new java.util.HashSet<>(java.util.Set.of( + "build", "buf", "generated-source", + "unary", "streaming", "tls", + "fault", "performance", "kubernetes", + "runbook", "adr")); + required.remove(missing); + return new GrpcReleaseEvidence( + java.util.Set.copyOf(required)); + } +} + +public final class GrpcStableReleaseGate { + private static final java.util.Set REQUIRED = + java.util.Set.of( + "build", "buf", "generated-source", + "unary", "streaming", "tls", + "fault", "performance", "kubernetes", + "runbook", "adr"); + + public GrpcReleaseDecision decide( + GrpcReleaseEvidence evidence) { + return evidence.passed().containsAll(REQUIRED) + ? GrpcReleaseDecision.APPROVED + : GrpcReleaseDecision.BLOCKED; + } +} +``` + +Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:grpc:grpc-testkit-core:test --tests 'io.backend.skeleton.grpc.release.GrpcStableReleaseGateTest' +./gradlew grpcStableTest +``` + +Expected: PASS for the focused test and the aggregate Stable suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/release/GrpcCompatibilityMatrix.java' 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/release/GrpcStableReleaseGate.java' 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/release/GrpcReleaseEvidence.java' 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/release/GrpcReleaseDecision.java' 'docs/runbooks/grpc-platform-operations.md' 'docs/adr/ADR-060-grpc-platform-boundary.md' 'docs/adr/ADR-061-grpc-execution-evidence.md' 'docs/adr/ADR-062-grpc-retry-idempotency.md' 'docs/adr/ADR-063-grpc-streaming-resume.md' 'docs/adr/ADR-064-grpc-discovery-kubernetes.md' 'docs/compatibility/grpc-support-matrix.md' 'modules/grpc/grpc-testkit-core/src/test/java/io/backend/skeleton/grpc/release/GrpcStableReleaseGateTest.java' +git commit -m "docs: complete grpc stable release gate" +``` diff --git a/docs/adr/ADR-GRPC-001-platform-family-and-registry-shape.md b/docs/adr/ADR-GRPC-001-platform-family-and-registry-shape.md new file mode 100644 index 00000000..f2eabfde --- /dev/null +++ b/docs/adr/ADR-GRPC-001-platform-family-and-registry-shape.md @@ -0,0 +1,55 @@ +# ADR-GRPC-001: The gRPC platform ships as a registered family, not as one adapter leaf + +- Status: accepted +- Date: 2026-08-30 +- Scope: `:grpc:*`, `:grpc-advanced:*`, `src/config/architecture/modules.json` + +## Context + +The two source plans describe a type-safe gRPC execution platform with its own API, SPI, adapters and +composition root: fifteen Stable modules under `modules/grpc` and sixteen Advanced ones under +`modules/grpc-advanced`, on Gradle Kotlin DSL, in package `io.backend.skeleton.grpc`, against +Spring Boot 4.1. + +None of that layout exists here. This repository uses Groovy DSL, a fail-closed module registry that +owns the leaf list, package root `dev.caskeleton`, and Spring Boot 4.0.8. The plans anticipate this: +their last Global Constraint says that when the repository structure differs, file paths are remapped +and the public contracts, invariants and test meanings are not changed. + +Two shapes were available. Fold the platform into the existing `:adapter:inbound:grpc` leaf as +packages — which is what the JPA, GraphQL, WebSocket and HTTP platforms did here — or register it as +a family the way `messaging:*` is registered. + +## Decision + +Register it as a family: twelve Stable leaves under `src/grpc/` and six Advanced ones under +`src/grpc-advanced/`. + +The deciding property is that this is not a layer of this application. Root `CLAUDE.md` already +describes `messaging:*` as "a vendored messaging platform: a product with its own API, SPI, adapters +and composition boundary, not a layer of this application", and the gRPC platform is the same shape +for the same reason — the application is meant to reach it the way it reaches a library, through an +application-owned port. The four platforms that became packages are all layers of this application; +this one is not. + +The split between `src/grpc/` and `src/grpc-advanced/` is not organisational. The Stable plan +requires that the Stable starter's build fail if it reaches an Advanced module, and separate Gradle +path prefixes make that a `verifyCleanArchitectureDependencies` failure rather than a review note: +`grpc-spring-boot-starter`'s registry entry names no advanced id, and it cannot acquire one silently. + +## Consequences + +**The registry grew from 44 leaves to 62.** That is a large registry change, made deliberately and in +one place. Every new leaf is `runtime_memberships: []`, so nothing ships until a second, explicit +decision moves it. + +**The advanced boundary is checked twice.** Once by the registry at build time, and once by +`GrpcStableBuildInvariant` at runtime, because a fat jar or a shaded artifact is assembled by +something the registry never sees. + +**Four testkit modules became four test lanes.** The plan's split exists so in-process results cannot +be mistaken for network results; this repository expresses that with `ca.strict-test-lane`, whose +lanes fail when they discover nothing and never serve an up-to-date result. `GrpcEvidenceGrade` keeps +the same rule inside the code, so a report cannot cite a contract run as transport evidence. + +**Codegen is not wired.** See ADR-GRPC-002. diff --git a/docs/adr/ADR-GRPC-002-schema-governance-without-protoc.md b/docs/adr/ADR-GRPC-002-schema-governance-without-protoc.md new file mode 100644 index 00000000..560d4948 --- /dev/null +++ b/docs/adr/ADR-GRPC-002-schema-governance-without-protoc.md @@ -0,0 +1,62 @@ +# ADR-GRPC-002: Schema governance runs without protoc and without the Buf CLI + +- Status: accepted +- Date: 2026-08-30 +- Scope: `:grpc:grpc-proto-contract`, `:grpc:grpc-codegen` + +## Context + +Stable Tasks 8 through 11 require proto style rules, Buf format/lint/breaking governance, a single +Java codegen owner, and a descriptor artifact whose consumer-compile result gates a release. + +Two of the tools those tasks name are absent from this toolchain. The Buf CLI is not installed. And +`protoc` is available through the Gradle protobuf plugin, but every leaf in this repository passes +spotless with google-java-format, checkstyle, SpotBugs at HIGH confidence, Error Prone and `-Werror` +— and generated protobuf sources pass none of them. Turning codegen on means excluding a source set +from five quality gates. + +There is precedent for such an exclusion: the `jmh` source set has `spotbugsJmh` and `checkstyleJmh` +disabled and Error Prone off. So the carve-out is available. It is also a decision about the quality +baseline of a leaf, taken for one task, and outside what this work was asked to change. + +`adapter:inbound:grpc` also carries a recorded decision in the opposite direction: its `CLAUDE.md` +forbids the protobuf plugin and `.proto` in that leaf, on the grounds that a consuming feature module +should own its schema. + +## Decision + +Commit the `.proto` sources and implement every rule the tasks require as executable Java, with no +protoc run and no Buf CLI invocation. + +`GrpcProtoContractValidator` reads `.proto` text and enforces proto3 syntax, the +`{organization}.{domain}.v{major}` package rule, `java_multiple_files`, a generated Java package +disjoint from the hand-written one, `_UNSPECIFIED` enum zero values, `reserved` declarations checked +against a supplied removal history, a well-known-type allowlist and a map-field allowlist. It runs +against the committed schema in its own test, so the shipped `.proto` files are live rather than +decorative. + +`GrpcBufPolicy` fixes the breaking gate at Buf's `FILE` category and names the four lifecycle stages +a compliant pipeline registers. `GrpcCodegenManifest` fixes one codegen owner and refuses a literal +generator version. `GrpcDescriptorArtifact`, `GrpcConsumerFixture` and `GrpcSchemaArtifactPublisher` +carry the schema hash, the descriptor digest and the per-consumer source-break report, and refuse a +publish that breaks a consumer or republishes a released version with different bytes. + +The committed `buf.yaml` states the same rules, so running the CLI in an environment that has it +reaches the same verdict. + +## Consequences + +**The invariants are enforced; the process is not run.** Everything Tasks 8 to 11 are about — which +schema changes are refused, which consumer breaks block a release, who owns generation — is a +build-checkable rule here. What is missing is the protoc invocation and the Buf binary. + +**Turning codegen on is a bounded change.** `GrpcCodegenManifest.caSkeleton()` already names the +owner, the managed version source, the build-directory output paths and the disjoint package policy +that a real plugin configuration has to satisfy. The work is a source-set carve-out and a plugin +block, not a redesign. + +**The fixtures use a text codec.** `GrpcTextCodec` gives the testkit a UTF-8 marshaller so the +in-process and Netty lanes can exercise interceptors, status mapping, metadata limits and stream +sequencing without generated stubs. Those contracts are properties of the platform and the transport, +not of any message shape, so the substitution costs nothing — and the lanes run today rather than +after codegen lands. diff --git a/docs/adr/ADR-GRPC-003-three-axis-execution-evidence.md b/docs/adr/ADR-GRPC-003-three-axis-execution-evidence.md new file mode 100644 index 00000000..aff2a68d --- /dev/null +++ b/docs/adr/ADR-GRPC-003-three-axis-execution-evidence.md @@ -0,0 +1,50 @@ +# ADR-GRPC-003: Transport, business and stream evidence are three axes, and none implies another + +- Status: accepted +- Date: 2026-08-30 +- Scope: `:grpc:grpc-core-api`, `:grpc:grpc-policy`, `:grpc:grpc-testkit` + +## Context + +A failed RPC produces a status code, and a status code is not an answer to the question the caller +actually has. `DEADLINE_EXCEEDED` on a mutation does not say whether the mutation happened; +`UNAVAILABLE` after the request was sent does not say the server never saw it; response headers +arriving does not say a transaction committed. + +Every one of those is a place where a plausible inference produces a duplicate write or a lost one, +and none of them is visible in a test that only exercises the happy path. + +## Decision + +Model what happened as three independent axes, and refuse the inferences between them. + +`GrpcTransportEvidence` records what the client observed on the wire, and distinguishes `NOT_SENT` — +the client watched its own send fail — from `UNOBSERVED`, which is every other case where nothing is +known. `GrpcBusinessEvidence` records what the application confirmed, with `COMMIT_UNKNOWN` as a real +state rather than a placeholder. `GrpcStreamEvidence` is a sealed hierarchy whose non-empty cases all +carry a position, because "partial" without a last sequence can be neither resumed nor reconciled. + +`GrpcExecutionEvidence` holds all three and rejects combinations nobody could have observed: a unary +call with stream evidence, or a request the client watched fail to send that nonetheless carries +business evidence. Promoting response headers to a confirmed commit is possible only by editing +`withResponseHeadersSeen`, which is one method rather than a plausible line in an interceptor. + +`GrpcCompletionOutcome.forMutation` derives what a caller may conclude, and defaults +`DEADLINE_EXCEEDED` and post-send `UNAVAILABLE` on a mutation to `COMPLETION_UNKNOWN`. + +The same types are used by the failure model and by the observation convention, so an incident has +one account of a call rather than two. + +## Consequences + +**A whole class of retry bug becomes unrepresentable.** `GrpcRetryEligibility` reads all three axes +plus the idempotency profile; a caller cannot reach "retry" from a status alone because the status +alone is not an input. + +**The fault lane has something to check.** `GrpcTransportEvidenceClassifier` turns a client's +observations into evidence and refuses to infer `NOT_SENT` from an unobserved state — and the lane +exercises it against a real connection dropped mid-call, not against a mock. + +**Callers must handle a third outcome.** `COMPLETION_UNKNOWN` is not a failure and not a success, and +a caller that treats it as either is wrong. `GrpcOperationStatusQuery` and `GrpcCompletionReconciler` +exist so that resolving it is a supported path rather than an exercise for the caller. diff --git a/docs/adr/ADR-GRPC-004-retry-ownership-and-durable-idempotency.md b/docs/adr/ADR-GRPC-004-retry-ownership-and-durable-idempotency.md new file mode 100644 index 00000000..dafadad7 --- /dev/null +++ b/docs/adr/ADR-GRPC-004-retry-ownership-and-durable-idempotency.md @@ -0,0 +1,52 @@ +# ADR-GRPC-004: One retry owner, and keyed mutations need a durable ledger + +- Status: accepted +- Date: 2026-08-30 +- Scope: `:grpc:grpc-policy`, `:grpc:grpc-operation-ledger-jpa`, `:grpc:grpc-core-api` + +## Context + +Three layers can retry a gRPC call: the application, the channel's service config, and a service +mesh. Their effects multiply. Three attempts at each layer is twenty-seven requests for one call, and +the load arrives exactly when the dependency is already failing. + +Separately, a mutation that is safe to repeat needs somewhere to record that it ran. Without one, a +retry after a lost response either duplicates the effect or drops it, and nothing distinguishes the +two afterwards. + +## Decision + +**Exactly one retry owner per channel.** `GrpcRetryOwner` has four values including `NONE`, which is a +decision rather than an omission. `GrpcServiceConfigPolicy` refuses an in-process retry entry when the +owner is the mesh or nobody, and `GrpcRetryOwnershipValidator` compares the service config's method +names against the policy catalog — a renamed method leaves its retry entry matching nothing, silently, +and the method then runs with channel defaults. + +**Retry eligibility reads the method, the evidence and the status together.** +`GrpcRetryEligibility` refuses a non-idempotent method outright, refuses any call whose stream +delivered a prefix, and turns a `DEADLINE_EXCEEDED` or post-send `UNAVAILABLE` mutation into +"resolve the completion first" rather than a retry. + +**A keyed mutation is retryable only with both a caller key and a durable ledger.** +`GrpcOperationLedger` is a port in `grpc-core-api`, so the policy layer can require durable +idempotency without depending on a database. Its `claim` contract is a single atomic insert-or-read +against a unique constraint: `JpaGrpcOperationLedger` inserts first and reads on constraint violation, +because a read-then-insert implementation has a window exactly as wide as the race it closes and +passes every test that does not run two attempts concurrently. + +The identity is caller fingerprint plus full method plus hashed key. All three are load-bearing: +without the caller, one tenant's key suppresses another's write; without the method, a key reused +across operations makes the second a replay of the first. + +## Consequences + +**A budget bounds retries as a fraction of traffic.** `GrpcRetryBudget` degrades to roughly no +retries when everything is failing, which is the behaviour that lets a dependency recover. + +**The ledger and the mutation should commit together.** `JpaGrpcOperationLedger` carries no +transaction annotations, deliberately: a `REQUIRES_NEW` would put the claim in its own transaction and +reintroduce the window where the write is durable and the claim is not. + +**A key reused for a different request is a caller error, not a duplicate.** The stored request +fingerprint turns that into `FAILED_PRECONDITION` rather than silently returning the first request's +answer. diff --git a/docs/adr/ADR-GRPC-005-server-streaming-single-writer-and-resume.md b/docs/adr/ADR-GRPC-005-server-streaming-single-writer-and-resume.md new file mode 100644 index 00000000..e6d885de --- /dev/null +++ b/docs/adr/ADR-GRPC-005-server-streaming-single-writer-and-resume.md @@ -0,0 +1,50 @@ +# ADR-GRPC-005: One writer per stream, a bounded queue, and resume that refuses to guess + +- Status: accepted +- Date: 2026-08-30 +- Scope: `:grpc:grpc-policy` + +## Context + +`StreamObserver` is not thread-safe, and the failure when two producers call `onNext` concurrently is +not an exception — it is interleaved bytes, which a client decodes as a corrupt message or, worse, as +a valid one it should never have received. + +Two further properties of server streams are easy to get wrong in ways that look healthy. A consumer +that falls behind either terminates the stream or silently loses messages, and the second leaves a +client with a stream that appears fine and is missing changes. And a reconnect either continues from +a position the server can still replay, or skips whatever is no longer there. + +## Decision + +**A bounded queue drained by one writer.** `GrpcSerializedStreamWriter` accepts messages from any +thread and hands them to the transport only from `flush`, which is synchronized. `write` returning +`ACCEPTED` means queued, and the name is deliberately not `sent`: the transport call returns as soon +as bytes are handed over, so no method here can honestly report delivery. + +**Both a message bound and a byte bound.** Either alone is unbounded in the other dimension. +`GrpcFlowControlPolicy` also takes the transport's own readiness signal, because a writer that relies +only on its queue bound produces as fast as it can allocate. + +**Termination is the default for a slow consumer.** `GrpcSlowConsumerPolicy.DROP_OLDEST` exists for +feeds whose business meaning tolerates loss, and is not the default, because a client cannot detect +dropped messages: the sequence numbers it sees are the ones it was sent. + +**Resume is refused rather than faked.** `GrpcStreamGapDetector` requires a signed, unexpired token +whose caller and filter fingerprints match the current request, refuses one whose snapshot version +moved, and returns `FULL_RESYNC_REQUIRED` when the cursor predates retained history. `GrpcResumeToken` +carries a key id so the signing key can rotate without invalidating every outstanding token. + +## Consequences + +**A stream carries an envelope, not a bare payload.** `GrpcStreamEnvelope` holds the stream id, +generation, sequence, snapshot version and resume token, because resume, gap detection and drain all +need a position and a generation. + +**Four clocks, not one.** `GrpcStreamLifetimePolicy` separates setup deadline, idle timeout, max +duration and heartbeat interval, and refuses combinations where one can never fire. Merging any pair +produces a familiar bug: an idle timeout used as a max duration kills healthy busy streams. + +**A heartbeat is a liveness signal and nothing else.** It is not an application acknowledgement and +not an ordering guarantee; `GrpcStreamHeartbeat` says so in the place somebody would otherwise reuse +it. diff --git a/docs/adr/ADR-GRPC-006-stable-discovery-and-kubernetes-routing.md b/docs/adr/ADR-GRPC-006-stable-discovery-and-kubernetes-routing.md new file mode 100644 index 00000000..135abd97 --- /dev/null +++ b/docs/adr/ADR-GRPC-006-stable-discovery-and-kubernetes-routing.md @@ -0,0 +1,68 @@ +# ADR-GRPC-006: Stable discovery is DNS and static, and a Kubernetes profile names who balances + +- Status: accepted +- Date: 2026-08-31 +- Scope: `:grpc:grpc-discovery`, `:grpc:grpc-client` + +## Context + +A gRPC channel's discovery configuration has a failure mode with no runtime symptom: it works, and +it does not do what the dashboard says it does. + +The specific case is `round_robin` over a Kubernetes Service ClusterIP. The Service is one virtual +address, so the resolver returns one endpoint and the client-side balancer has nothing to rotate +across; kube-proxy picks a pod at connect time, and an HTTP/2 connection is long-lived, so every +request from that client goes to the same pod for the life of the connection. Nothing fails. The +configuration says `round_robin`, the metrics show requests spread across clients rather than pods, +and the conclusion "we have client-side load balancing" is wrong in a way nobody is prompted to +check. + +The mirror-image mistake is `pick_first` over a headless record, which pins a client to one pod out +of many. + +Separately, a service mesh changes who owns retries, and a deployment that adds mesh routing without +removing its own retry policy has two retriers whose effects multiply. + +## Decision + +**Stable resolvers are Static, DNS and Unix domain socket; Stable load balancing is `pick_first` and +`round_robin`.** `GrpcDiscoveryPolicyValidator.requireStableScheme` refuses `xds`, `consul`, `etcd` +and `eureka` by name, with a message saying they are Advanced capabilities with their own control +plane and promotion gate rather than unknown schemes. + +**The pairing is checked against the resolved address count, not against intent.** +`GrpcResolverProfile` carries `expectedAddressCount`, and `GrpcStableLoadBalancer.effective` answers +whether the policy distributes anything over that many endpoints. A `round_robin` profile over one +address is a reported violation whose message says it describes spreading that is not happening. + +**A Kubernetes deployment names its routing mode**, and the mode implies both the balancer and the +retry owner. `GrpcKubernetesRoutingMode` has three values — `K8S_VIP`, `K8S_HEADLESS`, `MESH` — and +`GrpcKubernetesProfile` refuses a mesh profile whose retry owner retries in-process. + +**A profile that carries long-lived streams must state a reconnect budget and a readiness drain +grace.** A stream pins a client to one pod for its whole life, so every rollout, eviction and +scale-down ends it. `GrpcKubernetesProfileValidator` additionally reports a VIP profile carrying +long streams, and a drain grace shorter than the reconnect budget — the second means the pod stops +serving before its clients have finished reconnecting elsewhere. + +**A DNS profile must refresh.** `GrpcResolverProfile` refuses a zero refresh interval on DNS, +because a channel that resolved once at startup keeps sending to addresses that stopped existing an +hour ago, and the resulting `UNAVAILABLE` looks like an unhealthy deployment long after the rollout +finished. + +## Consequences + +**Two validators, not one.** `GrpcDiscoveryPolicyValidator` asks whether a balancer does anything +over the addresses it will see; `GrpcKubernetesProfileValidator` asks whether the deployment shape, +the retry owner and the stream obligations agree. A deployment can have a coherent resolver profile +and still have put retries in two places, so merging them would let one answer hide the other. + +**`expectedAddressCount` has to come from somewhere.** It is a declared number, and a declaration can +be wrong. It is still better than the alternative, which is not comparing anything: a wrong +declaration is a wrong statement somebody wrote down, and a missing one is a question nobody asked. +`GrpcChannelProfileValidator` takes resolved counts where they are known at startup and skips the +check where they are not, rather than guessing and failing on a name that cannot be resolved yet. + +**xDS is reachable, and not by this route.** It lives in `grpc-advanced-resilience` behind its +capability flag and its production approval, and `GrpcXdsStartupGuard.advertisableAsStableSupport()` +returns false so the Stable support statement cannot widen quietly. See ADR-GRPC-ADV-001. diff --git a/docs/adr/ADR-GRPC-ADV-001-capability-promotion-is-per-capability.md b/docs/adr/ADR-GRPC-ADV-001-capability-promotion-is-per-capability.md new file mode 100644 index 00000000..004528e2 --- /dev/null +++ b/docs/adr/ADR-GRPC-ADV-001-capability-promotion-is-per-capability.md @@ -0,0 +1,55 @@ +# ADR-GRPC-ADV-001: Each advanced capability has its own flag, its own grade and its own promotion + +- Status: accepted +- Date: 2026-08-30 +- Scope: `:grpc-advanced:*` + +## Context + +The advanced plan covers sixteen capabilities that differ by orders of magnitude in what they bring +with them. gRPC-Web adds a proxy. Reactor adds a dependency. xDS adds a control plane, its outage +modes, its own security boundary and its own version skew. Hedging duplicates production traffic. + +Bundling them under one flag makes enabling the cheapest of those the same decision as enabling the +most consequential. + +## Decision + +**One flag per capability**, under `ca-skeleton.grpc.advanced..enabled`, all off by +default. + +**Four grades.** `ADVANCED_STABLE` starts on its flag; `EXPERIMENTAL` additionally needs a separate +production approval, because the flag says somebody wanted the feature and the approval says somebody +accepted that its failure modes are not fully characterised; `WATCH` cannot start at all; `DISABLED` +is withdrawn. + +`GrpcAdvancedModuleGuard` distinguishes the three refusals — flag unset, grade unstartable, +production unapproved — because the remedy differs in each case. + +**Promotion evidence is per capability.** `GrpcAdvancedPromotionEvidence` is one record per +capability, so no promotion can drag another along; +`GrpcAdvancedPromotionGate.capabilitiesDraggedAlong` returns an empty list, and that is a tested +property rather than a claim. Two thresholds: seven days of soak plus complete evidence for +`ADVANCED_STABLE`, thirty for a Stable default, because the second means every deployment gets the +capability's dependencies and its failure modes. + +**Infrastructure is named per capability.** `GrpcAdvancedInfrastructureTestkit` records that +gRPC-Web needs a proxy, Servlet needs a container, xDS needs a stoppable control plane and Kotlin +needs a toolchain. A suite that runs without its infrastructure passes and establishes nothing, which +is worse than not having one. + +## Consequences + +**The Kotlin adapter fails closed here, and says why.** This repository has no Kotlin toolchain, so +`GrpcKotlinCompatibilityGate.supportableHere()` returns false. The four contract requirements — one +schema source, coroutine cancellation propagation, Flow backpressure inside the Stable bounds, +platform evidence types preserved — are checkable and are checked; only the compile lane is missing. + +**Edition 2026 cannot be used however its watch report reads.** `GrpcEdition2026Guard` is not +conditional on the report, because letting a status record also authorise use means a schema moves +onto an edition the moment somebody marks four fields SUPPORTED, with no promotion decision, no +consumer migration and no ADR. + +**xDS is not part of the Stable support statement.** It works, behind its flag and its approval; +`GrpcXdsStartupGuard.advertisableAsStableSupport()` returns false so a support matrix cannot widen +quietly. diff --git a/docs/compatibility/grpc-advanced-support-matrix.md b/docs/compatibility/grpc-advanced-support-matrix.md new file mode 100644 index 00000000..b3cf4913 --- /dev/null +++ b/docs/compatibility/grpc-advanced-support-matrix.md @@ -0,0 +1,62 @@ +# gRPC advanced capability support matrix + +Every capability in `:grpc-advanced:*`, its grade, and what it would take to raise it. +`GrpcAdvancedSupportMatrix` is the machine-readable form; `GrpcAdvancedCapability.defaultGrade` +carries the same values. + +All capabilities are off by default. Flags are `ca-skeleton.grpc.advanced..enabled`. + +## Grades + +| Grade | May start | Production needs a separate approval | +| --- | --- | --- | +| `ADVANCED_STABLE` | Yes | No | +| `EXPERIMENTAL` | Yes | Yes | +| `WATCH` | No | — | +| `DISABLED` | No | — | + +## Capabilities + +| Capability | Flag | Grade | Real infrastructure its evidence needs | +| --- | --- | --- | --- | +| Protobuf Edition 2024 | `edition-2024` | `ADVANCED_STABLE` | — | +| Protobuf Edition 2026 | `edition-2026` | `WATCH` | — | +| Client streaming | `client-streaming` | `ADVANCED_STABLE` | — | +| Bidirectional streaming | `bidi-streaming` | `ADVANCED_STABLE` | — | +| Manual flow control | `manual-flow-control` | `ADVANCED_STABLE` | — | +| Read-only unary hedging | `hedging` | `EXPERIMENTAL` | — | +| Custom name resolver | `custom-resolver` | `ADVANCED_STABLE` | — | +| Custom load balancer | `custom-load-balancer` | `EXPERIMENTAL` | — | +| Proxyless xDS | `xds` | `EXPERIMENTAL` | xDS control plane | +| gRPC-Web | `grpc-web` | `ADVANCED_STABLE` | gRPC-Web proxy | +| Servlet HTTP/2 | `servlet-compat` | `ADVANCED_STABLE` | Servlet container | +| Spring Integration bridge | `integration-bridge` | `ADVANCED_STABLE` | — | +| Reactor adapter | `reactor` | `ADVANCED_STABLE` | — | +| Kotlin coroutine / Flow | `kotlin` | `ADVANCED_STABLE` | Kotlin toolchain | +| Channelz / CSDS diagnostics | `channel-diagnostics` | `ADVANCED_STABLE` | — | + +## What the grades mean here, concretely + +**Grade is a statement about the contract, not about a deployment.** Every capability's contract is +implemented and tested in this repository. What no capability has is evidence from a real deployment: +`GrpcAdvancedPromotionEvidence` for each one is empty, and no promotion has been granted. + +**Four capabilities cannot produce meaningful evidence here at all**, because the infrastructure they +need is absent. `GrpcAdvancedInfrastructureTestkit.missingInfrastructure` names them, and a suite that +runs without its infrastructure passes and establishes nothing. + +**Kotlin is the sharpest case.** This repository has no Kotlin toolchain, so +`GrpcKotlinCompatibilityGate.supportableHere()` returns false and always will until one exists. The +four contract requirements — one schema source shared with Java, coroutine cancellation propagated, +Flow backpressure inside the Stable buffer bounds, platform evidence types preserved — are checkable +without a toolchain and are checked. The compile lane is not. + +## Promotion thresholds + +| To | Soak | Also required | +| --- | --- | --- | +| `ADVANCED_STABLE` | 7 days | compatibility evidence, security review, fault evidence, performance evidence, ADR, runbook, real-environment test | +| Stable default | 30 days | all of the above, plus a dependency, security and operational-cost review | + +`WATCH` becomes `EXPERIMENTAL` before anything else. Promotions are independent: promoting one +capability changes no other's grade. diff --git a/docs/compatibility/grpc-support-matrix.md b/docs/compatibility/grpc-support-matrix.md new file mode 100644 index 00000000..ab646de0 --- /dev/null +++ b/docs/compatibility/grpc-support-matrix.md @@ -0,0 +1,75 @@ +# gRPC platform support matrix + +What the Stable gRPC platform (`:grpc:*`) is certified against, what it is only checked against, and +what is merely watched. The distinction is the point: "works with Spring Boot" is not a statement +anyone can act on. + +`GrpcCompatibilityMatrix.caSkeleton()` is the machine-readable form of this table, and +`GrpcStableReleaseGate` blocks a release when a certified lane has no result or a failing one. + +## Lanes + +| Lane | Grade | Failure blocks a release | +| --- | --- | --- | +| Boot-managed platform (Spring Boot 4.0.8 BOM) | Certified | Yes | +| proto3 with explicit `optional` | Certified | Yes | +| `grpc-netty-shaded` | Certified | Yes | +| `grpc-netty` (unshaded) | Compatibility | No | +| Upstream gRPC Java version override | Compatibility | No | +| Protobuf Edition 2024 | Watch | No | +| Protobuf Edition 2026 | Watch | No | + +## Runtime baseline + +| | | +| --- | --- | +| Java | 21 | +| Spring Boot | 4.0.8 (the repository baseline; the plans assume 4.1) | +| io.grpc | `ext.grpcVersion` in `src/build.gradle` | +| Protobuf | `ext.protobufVersion` in `src/build.gradle` | +| Stable transport | Netty (shaded) | +| Stable RPC shapes | Unary, Server Streaming | +| Stable resolvers | Static, DNS, Unix domain socket | +| Stable load balancing | `pick_first`, `round_robin` | + +## Evidence grades + +A capability may only be advertised on evidence of a grade that can establish it. +`GrpcEvidenceGrade.requireCertifies` enforces this, and `GrpcReleaseEvidence.supports` refuses a +claim backed by the wrong lane. + +| Grade | Lane | Establishes | +| --- | --- | --- | +| `CONTRACT` | `grpcInProcessContractTest` | adapter, interceptor order, status mapping, validation, idempotency replay, context propagation | +| `TRANSPORT` | `grpcNettyContractTest` | HTTP/2, TLS, mTLS, metadata limit, message limit, GOAWAY, keepalive, graceful shutdown | +| `FAULT` | `grpcFaultTest` | connection loss, completion unknown, partial stream, evidence classifier | +| `PERFORMANCE` | `grpcPerformanceTest` | latency, stream saturation, executor saturation, drain budget | + +In-process results are never transport evidence. The in-process transport does not negotiate TLS, +does not frame HTTP/2 and does not enforce transport-level limits, so a suite that passes there has +tested the adapter and not the transport. + +## What is not supported + +| | Where it lives | +| --- | --- | +| Client streaming, bidirectional streaming | `grpc-advanced-streaming` | +| Manual flow control | `grpc-advanced-streaming` | +| Hedging | `grpc-advanced-resilience` | +| Custom name resolver, custom load balancer | `grpc-advanced-resilience` | +| xDS | `grpc-advanced-resilience` | +| gRPC-Web, Servlet HTTP/2, Spring Integration, Reactor, Kotlin | `grpc-advanced-compat` | +| Channelz / CSDS diagnostics | `grpc-advanced-diagnostics` | + +## Current release status + +Not released. Every `:grpc:*` leaf is `runtime_memberships: []` in the module registry, so the +platform is build-only: it compiles, its lanes run, and no deployed artifact carries it. + +Two release gate inputs are outstanding and are the work between here and a release: + +- **Performance baseline.** The performance lane runs and asserts shape — ordered percentiles, a gate + that reads them — rather than absolute numbers. A recorded baseline on a known runner is what turns + it into a regression gate. +- **Schema codegen.** No `protoc` runs in this build (ADR-GRPC-002), so the descriptor artifact and + the consumer-compile fixture are governed as policy rather than produced from a compiled schema. diff --git a/docs/runbooks/grpc-advanced-capabilities.md b/docs/runbooks/grpc-advanced-capabilities.md new file mode 100644 index 00000000..b215663e --- /dev/null +++ b/docs/runbooks/grpc-advanced-capabilities.md @@ -0,0 +1,98 @@ +# Runbook: gRPC advanced capabilities + +Scope: the `:grpc-advanced:*` family. Everything here is off by default and stays off until a +deployment names it. Nothing in this family ships in a runtime composition today. + +Flags are `ca-skeleton.grpc.advanced..enabled`. `GrpcAdvancedCapability` owns the list of +capability names; `GrpcAdvancedSupportMatrix` owns their current grades. + +--- + +## A capability refuses to start + +`GrpcAdvancedModuleGuard` gives three different refusals, and the remedy differs: + +| Message contains | Meaning | Remedy | +| --- | --- | --- | +| "its feature flag is not set" | Nobody enabled it | Set the property named in the message | +| "tracked rather than implemented" | Grade is `WATCH` | Nothing to do here; the capability is not implemented | +| "uncharacterised failure modes" | Grade is `EXPERIMENTAL` and this is production | Record a production approval, or run it outside production | + +The refusal message always names the property key, so the first case is a configuration line rather +than a support question. + +--- + +## xDS: the control plane went away + +**What you are seeing.** The control plane is unreachable and clients are still routing. + +**What it means.** `GrpcXdsFailurePolicy` serves the last-known-good snapshot, up to its staleness +bound. + +**What to do.** + +1. Check the snapshot's age. `SERVE_LAST_KNOWN_GOOD` is the healthy degraded state. +2. `STALE_BEYOND_BOUND` means the snapshot is older than the policy allows and is no longer trusted. + Beyond that bound, a decommissioned backend would otherwise keep receiving traffic indefinitely. +3. `NO_SNAPSHOT_YET` on a starting instance means it never reached the control plane. It fails after + the initial fetch timeout rather than starting with no routing. + +**Do not** add application-level retry policy while xDS is enabled. `GrpcXdsStartupGuard` refuses it, +because retry defined in two places has a winner that depends on resolution order rather than on a +decision. + +--- + +## gRPC-Web: a browser call hangs and then fails with no status + +**Almost always the proxy.** A gRPC status arrives as a trailer, and a browser cannot read a trailer +the proxy did not expose. Check that the proxy's CORS `expose_headers` includes `grpc-status` and +`grpc-message`; `GrpcWebProxyContract.violations` reports exactly this, and the reference +configuration in `envoy/envoy.yaml` shows it in place. + +**If the method is client- or bidirectional-streaming**, it cannot work over gRPC-Web at all — a +browser has no way to send a stream of messages. `GrpcWebCompatibilityGate` reports such a method +before it is exposed. + +--- + +## Servlet: a transport setting appears to be ignored + +It is ignored. The container owns the socket, so keepalive tuning, maximum connection age and +flow-control window tuning belong to it. `GrpcServletStartupValidator` refuses those settings at +startup rather than accepting and dropping them, because a setting that is silently ignored sends the +investigation somewhere else. + +A Servlet run never substitutes for Netty certification. + +--- + +## Hedging: backend load doubled + +**Expected, within a bound.** Hedging trades duplicate load for tail latency. +`GrpcHedgingResult` records both `duplicateBackendCalls` and `cancelledLoserAttempts`; a dashboard +showing only the latency improvement makes the trade look free. + +**What to check.** `GrpcHedgingBudget` caps hedges as a fraction of completed calls. If duplicate +load is above that fraction, the budget is not being consumed — which means something is issuing +hedges outside the coordinator. + +**Hedging is refused** for anything but a read-only unary method. A hedged mutation runs twice by +design, and an idempotency key does not help: the second attempt duplicates a success in progress +rather than retrying a failure. + +--- + +## Promoting a capability + +`GrpcAdvancedPromotionGate.evaluate` names every missing item. Promotion to `ADVANCED_STABLE` needs +compatibility evidence, a security review, fault evidence, performance evidence, an ADR, a runbook, a +real-environment test and seven days of soak. A Stable default needs thirty. + +Promotions are independent: promoting one capability changes no other's grade, and +`GrpcAdvancedSupportMatrix.apply` refuses a decision made against a different matrix state. + +Before citing a suite as evidence, check `GrpcAdvancedInfrastructureTestkit.missingInfrastructure`. +A suite that ran without the proxy, the container, the control plane or the toolchain it needs passed +and established nothing. diff --git a/docs/runbooks/grpc-platform-operations.md b/docs/runbooks/grpc-platform-operations.md new file mode 100644 index 00000000..9ce05877 --- /dev/null +++ b/docs/runbooks/grpc-platform-operations.md @@ -0,0 +1,142 @@ +# Runbook: gRPC platform operations + +Scope: the `:grpc:*` family. All of it is build-only today — every leaf's `runtime_memberships` is +empty — so nothing here fires in production yet. It is written now because the states it covers are +the ones an on-call cannot work out from first principles at three in the morning, and shipping the +behaviour before the runbook means the first person to meet one is doing that. + +Configuration lives under `ca-skeleton.grpc.platform.*` and is bound by `GrpcPlatformProperties`. +The platform does not start unless `ca-skeleton.grpc.platform.enabled=true`. + +--- + +## COMPLETION_UNKNOWN on a mutation + +**What you are seeing.** A client received `DEADLINE_EXCEEDED`, `UNAVAILABLE` or `INTERNAL` on a +state-changing call, and the response trailer `completion-outcome` reads `COMPLETION_UNKNOWN`. + +**What it means.** The server may have committed. This is not a failure and not a success; the status +code cannot distinguish them, which is why the outcome is carried separately. + +**What not to do.** Do not re-issue the call. Do not tell the caller it failed. Both are wrong half +the time, and which half is not knowable from the status. + +**What to do.** + +1. Take the `error-execution-id` from the trailers. It is the only link between what the client saw + and what the server did. +2. If the method is `IDEMPOTENCY_KEY_REQUIRED`, query the operation ledger with the caller + fingerprint, the full method name and the caller's key. `GrpcOperationStatusQuery` returns one of + `IN_PROGRESS`, `COMMITTED`, `FAILED_TERMINAL`, `NOT_FOUND` or `UNKNOWN`. +3. `COMMITTED` means return the stored outcome reference, not a freshly computed answer — the resource + may have changed since, and a new answer would describe the state at reconciliation time rather + than the state the caller's own call produced. +4. `NOT_FOUND` means the operation never started and is safe to re-issue. `FAILED_TERMINAL` means the + same. +5. `UNKNOWN` means the ledger could not be consulted. Nothing may be concluded. The case is queued by + `GrpcCompletionReconciler` and retried later. +6. If the method is not keyed, there is no ledger row. Resolve it against the business resource, or + escalate to the service owner. This is the case the keyed profile exists to avoid. + +**Escalate when** the reconciler's pending list grows across passes. That means the ledger is +unreachable rather than slow. + +--- + +## A stream ended with FULL_RESYNC_REQUIRED + +**What you are seeing.** A client's resume was refused and it was told to resynchronise. + +**What it means.** The server can no longer replay from the client's cursor. Either the snapshot +version moved, or the cursor predates retained history. + +**What to do.** Nothing on the server. The client is expected to discard its position and start a new +stream from a fresh snapshot. A client that instead retries the same token will keep receiving the +same answer. + +**Escalate when** it is happening to many clients at once. That usually means history retention was +reduced, or snapshots are rotating faster than clients reconnect. + +--- + +## A stream ended with SLOW_CONSUMER + +**What you are seeing.** Streams terminating with `SLOW_CONSUMER`, and +`grpc.stream.flow_control_stalls` rising. + +**What it means.** The consumer could not keep up with the bounded queue. The stream was terminated +rather than silently dropping messages, because a client cannot detect drops — the sequence numbers +it sees are the ones it was sent. + +**What to do.** + +1. Check whether the consumer is slow or the producer is fast. `grpc.stream.messages` against + `grpc.stream.lifetime` tells you the rate. +2. If the consumer is slow, the fix is on the consumer. Raising the queue bound moves the failure + later and makes it larger. +3. A resume is not available after this ending: the messages that overflowed the queue are gone, so + continuing from the last delivered sequence would silently skip them. The client resynchronises. + +--- + +## RESOURCE_EXHAUSTED under load + +**What you are seeing.** Calls refused with `RESOURCE_EXHAUSTED` and `GrpcAdmissionController` +reporting rejections. + +**What it means.** The server is at its concurrency and queue bounds and is shedding rather than +queueing. This is the designed behaviour: accepting work whose callers have already given up spends +capacity on nothing. + +**What to do.** + +1. Read `grpc.rpc.duration` and `grpc.rpc.queue_wait` separately. Queue time rising with duration flat + means the bottleneck is admission, not the work. +2. Check which saturation counter is moving — executor, channel or flow control. They look identical + in a latency graph and have different fixes. +3. Raising `ca-skeleton.grpc.platform.executor-queue-capacity` defers the problem; it does not remove + it. `GrpcExecutorProfile` refuses a queue above ten thousand for that reason. + +--- + +## A rollout is producing errors at every deploy + +**What you are seeing.** A burst of `UNAVAILABLE` or `CANCELLED` each time an instance goes away. + +**What it means.** The drain sequence is not completing, or is running out of order. + +**What to do.** + +1. `GrpcDrainResult` records what each drain achieved: completed and cancelled unary calls, signalled + and cancelled streams, and which phases ran. A drain that routinely force-cancels is the cause. +2. The order matters. Readiness flips first and nothing is refused during that window, because there + is a gap between an instance reporting unready and routing acting on it. Refusing during that gap + turns a clean rollout into a burst of errors at every deploy. +3. For long streams, check the Kubernetes profile's `streamReconnectBudget` and + `readinessDrainGrace`. A stream is pinned to one pod for its whole life, so every rollout ends it; + a profile with long streams and no reconnect budget has not decided what clients do next. + +--- + +## Verifying a deployment's configuration + +`GrpcPlatformSnapshotService` produces a secret-free snapshot for a caller on the admin network +holding an admin role. Both gates are required. + +`GrpcPlatformSnapshotService.driftAgainstRelease` compares a running snapshot with the release +manifest and reports schema version, method policy hash and per-channel profile differences. An +instance running a configuration the release did not ship is behind a whole class of incidents that +are otherwise diagnosed by reading logs. + +The snapshot carries hashes and names only. A field whose name looks like a credential is refused at +construction rather than redacted. + +--- + +## Things that are deliberately off + +- **Reflection in production.** `GrpcReflectionMode.defaultFor` returns `DISABLED` for `STAGE` and + `PROD`. Reflection publishes the whole schema to anyone who can open a connection. +- **Every advanced capability.** See `docs/runbooks/grpc-advanced-capabilities.md`. +- **The platform itself.** `ca-skeleton.grpc.platform.enabled` defaults to false, and every `:grpc:*` + leaf is build-only in the registry. diff --git a/docs/superpowers/plans/2026-08-30-grpc-platform-implementation.md b/docs/superpowers/plans/2026-08-30-grpc-platform-implementation.md new file mode 100644 index 00000000..cee058e1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-grpc-platform-implementation.md @@ -0,0 +1,117 @@ +# 타입 안전 gRPC 실행 플랫폼 — 실행 계획과 실행 결과 + +설계 SSOT: [docs/superpowers/specs/2026-08-30-grpc-platform-adaptation-design.md](../specs/2026-08-30-grpc-platform-adaptation-design.md) + +원본: +- `docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md` (Stable, Task 1–53) +- `docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md` (Advanced, Task 1–18) + +이 문서는 실행 전 계획이자 실행 결과 기록이다. 각 phase는 leaf 단위로 닫혔고, 닫힘 조건은 +`./gradlew :check` 통과다 — 즉 test + spotless + checkstyle + SpotBugs(HIGH) + +Error Prone/`-Werror` + 저장소 전역 게이트 전이 실행이다. + +## Phase 0 — 레지스트리와 스캐폴딩 + +- [x] `src/config/architecture/modules.json`에 18개 leaf 등록 (`grpc:*` 12, `grpc-advanced:*` 6) +- [x] leaf별 `build.gradle` 18개. io.grpc를 쓰는 leaf는 `grpc-bom`을 module scope로 import +- [x] `src/build.gradle`: `:grpc:` / `:grpc-advanced:` 를 plain JUnit+AssertJ 테스트 분기에 추가 + (`messaging:*`와 같은 이유 — core-api가 Spring도 io.grpc도 이름 부르지 않는다는 주장을 + 검증 가능하게 만든다) +- [x] `./gradlew --write-locks ...resolveAndLockAll` 로 lockfile 18개 생성 +- [x] `verifyCleanArchitectureDependencies` 통과 + +## Phase 1 — Foundation (`grpc-core-api`, Stable Task 1–7 + ledger port) + +- [x] Task 1 `GrpcStableModuleCatalog` / `GrpcStableBuildInvariant` +- [x] Task 2 `GrpcMethodName` / `GrpcServiceName` / `GrpcChannelProfileName` / `RpcType` / `GrpcStatusCode` +- [x] Task 3 `RpcIdempotencyProfile` / `WaitForReadyPolicy` / `GrpcMethodPolicy` / `GrpcMethodPolicyCatalog` +- [x] Task 4 `GrpcTransportEvidence` / `GrpcBusinessEvidence` / `GrpcStreamEvidence` / `GrpcExecutionEvidence` +- [x] Task 5 `GrpcFailureCategory` / `GrpcCompletionOutcome` / `GrpcFailureContext` / `GrpcPlatformException` +- [x] Task 6 `GrpcDeadlineProfile` / `GrpcDeadlineBudget` / `GrpcCancellationToken` / `GrpcDeadlineExceededException` +- [x] Task 7 `GrpcRequestContext` / `GrpcMetadataKey` / `GrpcMetadataBudget` / `GrpcClientIdentity` +- [x] 추가: `dev.caskeleton.grpc.ledger` port (`GrpcOperationLedger` 외 3) — 정책 계층이 DB에 의존하지 + 않고 durable idempotency를 요구할 수 있게 하기 위해 core-api에 둔다 + +## Phase 2 — Contract governance (`grpc-proto-contract`, `grpc-codegen`, Task 8–11) + +- [x] Task 8 `.proto` 2개 + `buf.yaml` + `GrpcProtoStyleManifest` / `GrpcProtoRuleViolation` / `GrpcProtoContractValidator` +- [x] Task 9 `GrpcBufPolicy` / `GrpcBreakingCategory` / `GrpcSchemaBaseline` +- [x] Task 10 `GrpcCodegenManifest` / `GrpcGeneratedPackagePolicy` / `GrpcCodegenOutput` +- [x] Task 11 `GrpcDescriptorArtifact` / `GrpcConsumerFixture` / `GrpcSchemaArtifactPublisher` +- 편차: protoc·Buf CLI 미실행. 근거는 ADR-GRPC-002 + +## Phase 3 — Policy (`grpc-policy`, Task 12·16·17·20·28–31·33·34·37–43) + +- [x] Task 12 validation, Task 16 context propagation, Task 17 status/rich error, Task 20 TLS/credential rotation +- [x] Task 28 deadline calculator, Task 29 cancellation coordinator +- [x] Task 30 service config/retry owner, Task 31 retry eligibility/budget/coordinator, Task 42 wait-for-ready +- [x] Task 33 idempotency interceptor, Task 34 completion reconciliation +- [x] Task 37–41 stream envelope·writer·flow control·resume token·lifetime +- [x] Task 43 message size / compression / payload boundary + +## Phase 4 — Server boundary (`grpc-server`, Task 13–15·18–19) + +- [x] Task 13 boundary rules + raw API import rule, Task 14 typed service adapter SPI +- [x] Task 15 interceptor 순서 계약, Task 18 Netty profile/executor/admission, Task 19 shaded parity + +## Phase 5 — Client / discovery / admin / observability / ledger + +- [x] `grpc-client` Task 24–27 +- [x] `grpc-discovery` Task 35–36 +- [x] `grpc-admin` Task 21–23·45 +- [x] `grpc-observability` Task 44 +- [x] `grpc-operation-ledger-jpa` Task 32 (entity + repository + migration + port impl) + +## Phase 6 — Composition과 인증 (`grpc-spring-boot-starter`, `grpc-testkit`) + +- [x] Task 52 properties / auto-configuration / startup validator +- [x] Task 46 in-process fixture, Task 47 실제 Netty + TLS/mTLS fixture +- [x] Task 48 fault point / scenario / evidence classifier +- [x] Task 49–50 unary·streaming contract suite, Task 51 performance budget/gate +- [x] Task 53 compatibility matrix / release evidence / release gate +- [x] strict test lane 4개 등록 및 실행: `grpcInProcessContractTest`, `grpcNettyContractTest`, + `grpcFaultTest`, `grpcPerformanceTest` + +## Phase 7 — Advanced (`grpc-advanced:*`, Advanced Task 1–18) + +- [x] A1·A18 `grpc-advanced-bootstrap` +- [x] A2·A3 `grpc-advanced-edition` +- [x] A4–A7 `grpc-advanced-streaming` +- [x] A8–A11 `grpc-advanced-resilience` +- [x] A12–A16 `grpc-advanced-compat` +- [x] A17 `grpc-advanced-diagnostics` + +## Phase 8 — 문서와 게이트 + +- [x] `src/grpc/CLAUDE.md`, `src/grpc-advanced/CLAUDE.md` +- [x] ADR-GRPC-001..005, ADR-GRPC-ADV-001 +- [x] `docs/runbooks/grpc-platform-operations.md`, `docs/runbooks/grpc-advanced-capabilities.md` +- [x] `docs/compatibility/grpc-support-matrix.md`, `docs/compatibility/grpc-advanced-support-matrix.md` +- [x] `verifyRunbookReferences`, `verifyDocumentedLeafCount` 통과 + +## Phase 9 — 2026-08-31 계획 대조 감사 + +사용자가 "빠짐없이 반영한게 맞나"를 물어 계획의 `Files:` 절 전체를 기계 대조했다. 결과와 조치는 +설계 문서 §6이 SSOT다. 요약: + +- [x] 대조 스크립트 실행 → 초기 결과 present=316 / missing=38 +- [x] 실제 누락 6건 보완: `GrpcKubernetesProfileValidator`, `ADR-GRPC-006`(discovery/Kubernetes), + xDS `bootstrap.json`, `control-plane-snapshot.json`, `DocumentClientFixture`, + `buf.gen.yaml`+`buf.lock` +- [x] 추가한 픽스처는 전부 실제 검사에 물렸다 — 장식이 되지 않도록: + `GrpcXdsStartupGuard.bootstrapMismatches`(namespace 불일치·비TLS control plane), + redactor가 실제 모양의 CSDS 데이터로 검증, `GrpcConsumerFixture.fromJavaSource`가 fixture + 소스에서 요구사항을 역산 +- [x] 테스트 클래스 17건을 계획이 명시한 이름으로 분리 +- [x] 재대조 → present=341 / missing=13, 잔여 13건은 전부 §6.1–6.4의 기록된 편차 + (ADR 파일명 6, codegen convention plugin 3, TLS 인증서 3, Kotlin `.kt` 1) + +## 남은 작업 (이 계획 범위 밖, 별도 결정 필요) + +1. **런타임 투입.** 신규 leaf 전부 `runtime_memberships: []`다. `app-bootstrap`에 배선하려면 registry + membership 변경 + `verifyRuntimeModuleMembership` 통과가 선행이며, 그것은 별도 결정이다. +2. **`adapter:inbound:grpc` 브리지.** registry의 `allowed_dependencies`에 `grpc-core-api`·`grpc-server`를 + 추가하고 typed service adapter를 배선하는 작업. 플랫폼이 green이 된 지금이 시작점이다. +3. **protoc / Buf CLI 활성화.** ADR-GRPC-002가 조건과 비용을 기록한다. +4. **performance baseline 기록.** 현재 lane은 shape만 검증한다. 알려진 runner에서의 baseline이 + regression gate를 만든다. diff --git a/docs/superpowers/specs/2026-08-30-grpc-platform-adaptation-design.md b/docs/superpowers/specs/2026-08-30-grpc-platform-adaptation-design.md new file mode 100644 index 00000000..0f58a333 --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-grpc-platform-adaptation-design.md @@ -0,0 +1,208 @@ +# 타입 안전 gRPC 실행 플랫폼 — 이 저장소 규약으로의 어댑팅 설계 + +두 계획 문서를 이 저장소(`ca-skeleton`)의 실제 구조·정책·빌드 게이트에 맞춰 실행 가능한 형태로 +옮기는 설계다. + +- 원본 A: `docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md` (Stable, Task 1–53) +- 원본 B: `docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md` (Advanced, Task 1–18) + +원본은 `modules/grpc/**` · Gradle Kotlin DSL · 패키지 `io.backend.skeleton.grpc` · Spring Boot 4.1을 +전제한다. 이 저장소는 `src/**` · Groovy DSL · `dev.caskeleton` · Spring Boot 4.0.8 · fail-closed +module registry를 쓴다. 원본 Global Constraint의 마지막 항목이 이 어댑팅을 명시적으로 허용한다: +"실제 저장소 구조가 예상 경로와 다르면 파일 경로만 매핑하고 공개 계약·불변 조건·테스트 의미는 +변경하지 않는다." + +## 1. 패밀리 위치 — 왜 `adapter:inbound:grpc` 확장이 아닌가 + +원본이 기술하는 것은 인바운드 어댑터 하나가 아니라 **자기 API·SPI·adapter·조립 경계를 가진 벤더드 +RPC 플랫폼**이다. 이 저장소에는 그 형태의 선례가 이미 있다 — `messaging:*`. root `CLAUDE.md`가 +직접 그렇게 규정한다: "vendored messaging platform: a product with its own API, SPI, adapters and +composition boundary, not a layer of this application". + +따라서 gRPC 플랫폼도 같은 자리에 둔다. + +```text +src/grpc/ → :grpc:* Stable 플랫폼 (12 leaf) +src/grpc-advanced/ → :grpc-advanced:* Advanced/Experimental (6 leaf) +src/adapter/inbound/grpc 기존 인바운드 전송 어댑터 (그대로 유지) +``` + +`grpc-advanced`를 별도 디렉터리·별도 Gradle prefix로 分離하는 이유는 원본 Stable Task 1의 불변 +조건 "Stable starter가 `modules/grpc-advanced`를 참조하면 build를 실패시킨다"를 registry의 +`allowed_dependencies`만으로 기계 검증할 수 있게 만들기 위해서다. `verifyCleanArchitectureDependencies`가 +그 게이트다. + +패키지 루트는 `dev.caskeleton.grpc` / `dev.caskeleton.grpc.advanced`다. + +## 2. Leaf 매핑 — 원본 31개 모듈 → 이 저장소 18개 leaf + +원본 모듈 경계 중 **이 저장소가 이미 다른 메커니즘으로 표현하는 것**만 합친다. 능력(capability)은 +하나도 버리지 않는다. + +### Stable — `src/grpc/` + +| leaf | 원본 모듈 | 담는 Task | 의존 | +| --- | --- | --- | --- | +| `grpc-core-api` | grpc-core-api | 2–7 | (없음, Java stdlib) | +| `grpc-proto-contract` | grpc-proto-contract | 8 | core-api | +| `grpc-codegen` | grpc-codegen | 9–11 | core-api, proto-contract | +| `grpc-policy` | grpc-policy | 12, 16, 17, 20, 28–31, 33, 34, 37–43 | core-api | +| `grpc-server` | grpc-server | 13–15, 18, 19 | core-api, policy | +| `grpc-client` | grpc-client | 24–27 | core-api, policy | +| `grpc-discovery` | grpc-discovery | 35, 36 | core-api, client | +| `grpc-admin` | grpc-admin | 21–23, 45 | core-api, server | +| `grpc-observability` | grpc-observability | 44 | core-api | +| `grpc-operation-ledger-jpa` | grpc-operation-ledger-jpa | 32 | core-api | +| `grpc-spring-boot-starter` | grpc-spring-boot-starter | 52 | 위 전부 | +| `grpc-testkit` | testkit-core + -inprocess + -netty + -fault | 46–51, 53 | 위 전부 | + +**testkit 4개를 1개로 합친 근거.** 원본이 testkit을 넷으로 쪼갠 목적은 "in-process 증거를 실제 +네트워크 증거로 오인하지 않게 한다"이다. 이 저장소는 그 목적을 모듈 경계가 아니라 **strict test +lane** 컨벤션(`ca.strict-test-lane`)으로 이미 표현한다 — lane은 태그/소스셋/명시 테스트 중 하나로만 +선택하고, 아무것도 실행하지 않으면 실패하며, up-to-date 결과를 제공하지 않는다. 그래서 네 모듈은 +`grpc-testkit` 한 leaf 안의 네 lane이 된다: +`grpcInProcessContractTest`, `grpcNettyContractTest`, `grpcFaultTest`, `grpcPerformanceTest`. +원본의 "in-process는 HTTP/2·TLS 증거가 아니다"는 lane 분리와 `GrpcEvidenceGrade`로 강제한다. + +### Advanced — `src/grpc-advanced/` + +| leaf | 원본 모듈 | 담는 Task | +| --- | --- | --- | +| `grpc-advanced-bootstrap` | grpc-advanced-bootstrap | A1, A18 | +| `grpc-advanced-edition` | grpc-edition-2024 + grpc-edition-2026-experimental | A2, A3 | +| `grpc-advanced-streaming` | grpc-client-streaming + grpc-bidi-streaming + grpc-manual-flow-control | A4–A7 | +| `grpc-advanced-resilience` | grpc-hedging + grpc-custom-resolver + grpc-custom-load-balancer + grpc-xds | A8–A11 | +| `grpc-advanced-compat` | grpc-web + grpc-servlet-compat + grpc-integration-bridge + grpc-reactor + grpc-kotlin | A12–A16 | +| `grpc-advanced-diagnostics` | grpc-channel-diagnostics | A17 | + +Advanced 모듈은 capability마다 leaf를 나누는 대신 **capability grade와 feature flag** +(`GrpcAdvancedCapability` / `GrpcAdvancedFeatureFlags`)로 분리한다. A18의 요구사항 +"Edition, streaming, xDS, gRPC-Web, Servlet, language adapter가 서로의 승격을 묶지 않는다"는 +leaf 경계가 아니라 capability별 독립 promotion evidence로 표현되므로, 합쳐도 그 불변 조건은 유지된다. + +## 3. 원본과 달라지는 지점 (deviation)과 근거 + +| # | 원본 | 이 저장소 | 근거 | +| --- | --- | --- | --- | +| D1 | Spring Boot 4.1 BOM | Spring Boot 4.0.8 BOM | 저장소 실제 baseline(`src/build.gradle:13`). BOM이 SSOT라는 계약 자체는 유지 | +| D2 | Boot-managed Spring gRPC starter | self-managed `io.grpc` (`ext.grpcVersion`) | 기존 기록된 결정(`adapter/inbound/grpc/README.md` "왜 self-managed Netty 인가"). starter 커플링 회피 | +| D3 | Gradle Kotlin DSL, `settings.gradle.kts` | Groovy DSL + `config/architecture/modules.json` | registry가 leaf 목록의 SSOT이고 settings는 그것을 읽기만 한다 | +| D4 | `io.backend.skeleton.grpc` | `dev.caskeleton.grpc` | 저장소 기본 패키지 | +| D5 | Buf CLI (`bufLint`/`bufBreaking` 등) | 저장소 소유 규칙 엔진 + Gradle verify task | Buf CLI 바이너리가 이 환경에 없다. lint/format/breaking **규칙**을 Java로 구현해 동일 판정을 내리고, CLI는 동일 규칙을 재확인하는 선택 경로로 남긴다 | +| D6 | protoc/grpc-java codegen을 빌드에서 실행 | `.proto` 소스 + codegen **정책·descriptor 계약**만 실행, protoc 실행은 명시적 확장점 | 아래 별도 절 | +| D7 | `grpc-kotlin`의 `.kt` 소스 | Java 쪽 coroutine/Flow **경계 계약**만 | 저장소에 Kotlin 플러그인·소스셋이 없다. A16의 5개 요구사항 중 4개(스키마 단일 소스, cancellation 전파 계약, backpressure 우회 금지, evidence 타입 보존)는 Java 계약으로 표현 가능하고, Kotlin 툴체인 lane은 `GrpcKotlinCompatibilityGate`가 미충족으로 fail-closed 판정한다 | +| D8 | 새 leaf가 곧 런타임 | 신규 leaf 전부 `runtime_memberships: []` (build-only) | `messaging:*`가 처음 착지한 방식과 동일. 런타임 투입은 registry membership 변경 + `verifyRuntimeModuleMembership` 통과가 선행 조건이며, 그것은 별도 결정이다 | + +### D6 — protoc 실행을 지금 켜지 않는 이유 + +이 저장소의 모든 leaf는 예외 없이 spotless(google-java-format) · checkstyle · spotbugs(HIGH) · +errorprone · `-Werror`를 통과해야 한다(`src/build.gradle`의 `configure(subprojects)` 블록). protoc가 +만든 소스는 그 어느 것도 통과하지 못하므로, 실제 codegen을 켜려면 해당 소스셋에서 다섯 게이트를 +모두 끄는 carve-out이 필요하다. 저장소에 선례는 있다(`jmh` 소스셋). 하지만 그 carve-out은 Task +10 하나를 위해 품질 게이트를 여는 결정이고, 그 결정은 이 작업 범위 밖의 승인 사항이다. + +그래서 Task 8–11의 **불변 조건**은 전부 실행 가능한 형태로 구현한다: +proto style 규칙 검증, 삭제 필드 `reserved` 이력 대조, Buf breaking category(`FILE`) 판정, +generated package와 hand-written package 겹침 금지, descriptor+schema hash 릴리스 아티팩트, +consumer fixture 실패 시 릴리스 차단. 빠지는 것은 protoc 프로세스 호출 하나이고, +`GrpcCodegenManifest`가 그 지점을 단일 owner로 고정한 채 비워둔다. + +## 4. 유지되는 원본 불변 조건 (변경 없음) + +- 실행 증거 3축(Transport/Business/Stream) 분리, `RESPONSE_HEADERS_SEEN` → `COMMIT_CONFIRMED` 자동 승격 금지 +- `DEADLINE_EXCEEDED` mutation = `COMPLETION_UNKNOWN` 후보, `UNAVAILABLE`만으로 상태 변경 RPC 재호출 금지 +- `NON_IDEMPOTENT`에 explicit retry·hedging 금지, explicit retry owner는 하나 +- Stable Unary는 positive deadline 필수 +- Server Streaming: bounded queue + single serialized writer, partial delivery 후 whole-call retry 금지 +- Stable RPC 유형은 Unary·Server Streaming, Client/Bidi는 Advanced +- production reflection 기본 비활성, dev·stage·prod TLS 필수, trust-all 금지 +- Stable resolver = Static·DNS, Stable LB = pick_first·round_robin +- metric tag에 raw metadata/payload/actor/tenant/object/stream/idempotency ID 금지 +- Netty가 Stable certification transport, in-process는 network/TLS 증거가 아님 + +## 5. 애플리케이션이 이 패밀리에 도달하는 경로 + +`messaging:*`의 MSG-015(bridge 부재)를 반복하지 않는다. 계약상의 경로는 + +```text +application-owned port → adapter:inbound:grpc (typed service adapter) → :grpc:grpc-server SPI +``` + +이고, Stable Task 13/14가 그 경계를 소유한다(`GrpcApplicationBoundaryRules`, +`GrpcRawApiImportRule`, `GrpcServiceAdapter`). 플랫폼이 green이 된 뒤 마지막 단계에서 +`adapter-inbound-grpc`의 `allowed_dependencies`에 `grpc-core-api`·`grpc-server`를 추가하고 +브리지를 배선한다. 그 전까지 플랫폼은 self-contained build-only다. + +## 6. 원본 파일 목록 대비 대조 (2026-08-31 감사) + +두 계획이 `Files:` 절에 명시한 산출물 전체를 기계적으로 대조했다. 감사 스크립트는 각 Task의 파일 +basename이 저장소에 존재하는지 확인한다(build 산출물 제외, 단 annotation processor가 생성하는 +`spring-configuration-metadata.json`은 build 출력에서 확인). + +```text +STABLE (Task 1–53) : present=250 missing=11 +ADVANCED (Task 1–18) : present= 91 missing= 2 +TOTAL : present=341 missing=13 +``` + +잔여 13건은 전부 아래 편차로 설명된다. **행동이 빠진 것은 없다.** + +### 6.1 ADR 파일명 (6건) — 저장소 명명 규약 + +계획은 `ADR-060` ~ `ADR-065` 연번을 쓴다. 이 저장소의 `docs/adr/`는 접두사 규약을 쓴다 +(`ADR-WS-001`, `ADR-MONGO-004`, `ADR-WEB-ADV-003`). 내용은 1:1이다. + +| 계획 | 이 저장소 | +| --- | --- | +| ADR-060 platform-boundary | `ADR-GRPC-001-platform-family-and-registry-shape.md` | +| ADR-061 execution-evidence | `ADR-GRPC-003-three-axis-execution-evidence.md` | +| ADR-062 retry-idempotency | `ADR-GRPC-004-retry-ownership-and-durable-idempotency.md` | +| ADR-063 streaming-resume | `ADR-GRPC-005-server-streaming-single-writer-and-resume.md` | +| ADR-064 discovery-kubernetes | `ADR-GRPC-006-stable-discovery-and-kubernetes-routing.md` | +| ADR-065 advanced-promotion | `ADR-GRPC-ADV-001-capability-promotion-is-per-capability.md` | + +계획에 없던 `ADR-GRPC-002-schema-governance-without-protoc.md`가 추가로 있다 — D6 결정을 기록한다. + +### 6.2 codegen convention plugin 3건 — D6 + +`io.backend.grpc-buf-conventions.gradle.kts`, `io.backend.grpc-codegen-conventions.gradle.kts`, +그 `.properties`. protoc를 실행하지 않으므로 실행할 convention plugin이 없다. 이들이 고정했을 결정은 +`GrpcCodegenManifest`·`GrpcCodegenOutput`·`GrpcGeneratedPackagePolicy`가 Java로 강제하고, +`buf.yaml`·`buf.gen.yaml`·`buf.lock`이 CLI가 있는 환경에서 같은 판정을 내리도록 커밋되어 있다. + +### 6.3 TLS 인증서 3건 — 런타임 생성으로 대체 + +`ca.crt`, `server.crt`, `client.crt`. 커밋된 인증서는 만료되고, 커밋된 개인키는 개인키다. +`GrpcTlsTestMaterial`이 JDK `keytool`로 fixture마다 PKCS12를 생성하고 `close()`가 지운다. Netty의 +`SelfSignedCertificate`는 JDK 21에서 `sun.security.x509` 미export로 실패하므로 쓰지 않았다. + +### 6.4 Kotlin 소스 1건 — D7 + +`GrpcCoroutineAdapter.kt`. 저장소에 Kotlin 툴체인이 없다. 계약 요구 4건은 Java로 검증하고 +(`GrpcKotlinProfile`, `GrpcCoroutineContextBridge`), compile lane은 +`GrpcKotlinCompatibilityGate.supportableHere()`가 `false`로 fail-closed다. + +### 6.5 2026-08-31 감사에서 실제로 메꾼 것 (6건) + +감사가 아니었으면 남았을 것들이다. + +| 항목 | 조치 | +| --- | --- | +| `GrpcKubernetesProfileValidator` | 이름 있는 타입으로 분리. VIP+장기스트림, drain grace < reconnect budget 두 규칙 추가 | +| ADR-064 상당 (discovery/Kubernetes) | `ADR-GRPC-006` 작성 | +| xDS `bootstrap.json` | 테스트 리소스로 추가 + `GrpcXdsStartupGuard.bootstrapMismatches`가 실제로 대조 (namespace 불일치·비TLS control plane 검출) | +| `control-plane-snapshot.json` | 테스트 리소스로 추가 + redactor가 실제 모양의 데이터로 검증됨 | +| `DocumentClientFixture.java` + fixture `build.gradle.kts` | 추가. `GrpcConsumerFixture.fromJavaSource`가 fixture 소스에서 service·method·package 요구사항을 **역산**하므로 손으로 적은 목록이 아니다 | +| `buf.gen.yaml`, `buf.lock` | 추가 + proto contract 테스트가 내용을 검증 | + +### 6.6 테스트 클래스 17건 — 감사 후 계획대로 분리 + +감사 시점에 leaf별로 통합돼 있던 테스트를 계획이 명시한 이름으로 분리했다. 분리 전에도 모든 +타입이 실제로 검증되고 있었으나(통합 클래스가 해당 타입을 참조), 계획과의 추적성을 위해 나눴다: + +`GrpcNettyVariantSelectorTest`, `GrpcReflectionPolicyTest`, `GrpcClientMetadataPolicyTest`, +`GrpcKubernetesProfileTest`, `GrpcEdition2026GuardTest`, `GrpcClientStreamPolicyTest`, +`GrpcClientMessageDeduplicatorTest`, `GrpcBidiSequenceTrackerTest`, `GrpcDemandControllerTest`, +`GrpcHedgingEligibilityTest`, `GrpcResolverSafetyPolicyTest`, `GrpcLoadBalancerSafetyPolicyTest`, +`GrpcXdsStartupGuardTest`, `GrpcWebCompatibilityGateTest`, `GrpcServletStartupValidatorTest`, +`GrpcIntegrationBridgePolicyTest`, `GrpcReactorContextBridgeTest`, `GrpcKotlinCompatibilityGateTest`. diff --git a/src/build.gradle b/src/build.gradle index dc016e58..ba2ca0e4 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -477,12 +477,15 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) { } dependencies { - // The messaging platform leaves own a broker-neutral public contract. Keeping their test - // classpath on plain JUnit + AssertJ is what makes "messaging-core-api has no Spring - // dependency" verifiable rather than aspirational; leaves that genuinely need a Spring + // The messaging and gRPC platform leaves own a transport-neutral public contract. Keeping + // their test classpath on plain JUnit + AssertJ is what makes "messaging-core-api has no + // Spring dependency" — and the same claim for grpc-core-api, which additionally may not + // name io.grpc — verifiable rather than aspirational; leaves that genuinely need a Spring // test context add it in their own build file. if (project.path in [':domain-core', ':application-core', ':shared-contract'] || - project.path.startsWith(':messaging:')) { + project.path.startsWith(':messaging:') || + project.path.startsWith(':grpc:') || + project.path.startsWith(':grpc-advanced:')) { testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'org.assertj:assertj-core' } else { diff --git a/src/config/architecture/modules.json b/src/config/architecture/modules.json index c00e94b0..4b8e860c 100644 --- a/src/config/architecture/modules.json +++ b/src/config/architecture/modules.json @@ -587,6 +587,203 @@ "messaging-transport-spi" ], "runtime_memberships": [] + }, + { + "id": "grpc-core-api", + "gradle_path": ":grpc:grpc-core-api", + "source_path": "src/grpc/grpc-core-api", + "allowed_dependencies": [], + "runtime_memberships": [] + }, + { + "id": "grpc-proto-contract", + "gradle_path": ":grpc:grpc-proto-contract", + "source_path": "src/grpc/grpc-proto-contract", + "allowed_dependencies": [ + "grpc-core-api" + ], + "runtime_memberships": [] + }, + { + "id": "grpc-codegen", + "gradle_path": ":grpc:grpc-codegen", + "source_path": "src/grpc/grpc-codegen", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-proto-contract" + ], + "runtime_memberships": [] + }, + { + "id": "grpc-policy", + "gradle_path": ":grpc:grpc-policy", + "source_path": "src/grpc/grpc-policy", + "allowed_dependencies": [ + "grpc-core-api" + ], + "runtime_memberships": [] + }, + { + "id": "grpc-server", + "gradle_path": ":grpc:grpc-server", + "source_path": "src/grpc/grpc-server", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-policy" + ], + "runtime_memberships": [] + }, + { + "id": "grpc-client", + "gradle_path": ":grpc:grpc-client", + "source_path": "src/grpc/grpc-client", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-policy" + ], + "runtime_memberships": [] + }, + { + "id": "grpc-discovery", + "gradle_path": ":grpc:grpc-discovery", + "source_path": "src/grpc/grpc-discovery", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-client" + ], + "runtime_memberships": [] + }, + { + "id": "grpc-admin", + "gradle_path": ":grpc:grpc-admin", + "source_path": "src/grpc/grpc-admin", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-server" + ], + "runtime_memberships": [] + }, + { + "id": "grpc-observability", + "gradle_path": ":grpc:grpc-observability", + "source_path": "src/grpc/grpc-observability", + "allowed_dependencies": [ + "grpc-core-api" + ], + "runtime_memberships": [] + }, + { + "id": "grpc-operation-ledger-jpa", + "gradle_path": ":grpc:grpc-operation-ledger-jpa", + "source_path": "src/grpc/grpc-operation-ledger-jpa", + "allowed_dependencies": [ + "grpc-core-api" + ], + "runtime_memberships": [] + }, + { + "id": "grpc-spring-boot-starter", + "gradle_path": ":grpc:grpc-spring-boot-starter", + "source_path": "src/grpc/grpc-spring-boot-starter", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-proto-contract", + "grpc-codegen", + "grpc-policy", + "grpc-server", + "grpc-client", + "grpc-discovery", + "grpc-admin", + "grpc-observability", + "grpc-operation-ledger-jpa" + ], + "runtime_memberships": [] + }, + { + "id": "grpc-testkit", + "gradle_path": ":grpc:grpc-testkit", + "source_path": "src/grpc/grpc-testkit", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-proto-contract", + "grpc-codegen", + "grpc-policy", + "grpc-server", + "grpc-client", + "grpc-discovery", + "grpc-admin", + "grpc-observability", + "grpc-operation-ledger-jpa" + ], + "runtime_memberships": [] + }, + { + "id": "grpc-advanced-bootstrap", + "gradle_path": ":grpc-advanced:grpc-advanced-bootstrap", + "source_path": "src/grpc-advanced/grpc-advanced-bootstrap", + "allowed_dependencies": [ + "grpc-core-api" + ], + "runtime_memberships": [] + }, + { + "id": "grpc-advanced-edition", + "gradle_path": ":grpc-advanced:grpc-advanced-edition", + "source_path": "src/grpc-advanced/grpc-advanced-edition", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-proto-contract", + "grpc-advanced-bootstrap" + ], + "runtime_memberships": [] + }, + { + "id": "grpc-advanced-streaming", + "gradle_path": ":grpc-advanced:grpc-advanced-streaming", + "source_path": "src/grpc-advanced/grpc-advanced-streaming", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-policy", + "grpc-advanced-bootstrap" + ], + "runtime_memberships": [] + }, + { + "id": "grpc-advanced-resilience", + "gradle_path": ":grpc-advanced:grpc-advanced-resilience", + "source_path": "src/grpc-advanced/grpc-advanced-resilience", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-policy", + "grpc-client", + "grpc-discovery", + "grpc-advanced-bootstrap" + ], + "runtime_memberships": [] + }, + { + "id": "grpc-advanced-compat", + "gradle_path": ":grpc-advanced:grpc-advanced-compat", + "source_path": "src/grpc-advanced/grpc-advanced-compat", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-policy", + "grpc-server", + "grpc-client", + "grpc-advanced-bootstrap" + ], + "runtime_memberships": [] + }, + { + "id": "grpc-advanced-diagnostics", + "gradle_path": ":grpc-advanced:grpc-advanced-diagnostics", + "source_path": "src/grpc-advanced/grpc-advanced-diagnostics", + "allowed_dependencies": [ + "grpc-core-api", + "grpc-client", + "grpc-advanced-bootstrap" + ], + "runtime_memberships": [] } ] } diff --git a/src/grpc-advanced/CLAUDE.md b/src/grpc-advanced/CLAUDE.md new file mode 100644 index 00000000..9d60b56e --- /dev/null +++ b/src/grpc-advanced/CLAUDE.md @@ -0,0 +1,77 @@ +# grpc-advanced — local authority for the advanced gRPC capabilities + +이 문서는 `grpc-advanced:*` family의 **local authority**다. leaf 목록·gradle path·허용 의존성은 +`src/config/architecture/modules.json`이 SSOT다. Root 정책(`CLAUDE.md` / `AGENTS.md`)과 충돌하면 +root가 이긴다. + +이 family는 Stable gRPC 플랫폼(`grpc:*`)이 **의도적으로 제외한** 능력들을 담는다. 별도 디렉터리와 +별도 Gradle prefix인 이유는 하나다: "Stable starter가 advanced module을 참조하면 build가 실패한다"는 +불변 조건을 registry의 `allowed_dependencies`만으로 기계 검증할 수 있게 하기 위해서다. + +## 의존 방향 + +```text +grpc-advanced:* → grpc:* (허용) +grpc:* → grpc-advanced:* (금지 — registry가 거부한다) +``` + +`grpc-spring-boot-starter`의 registry 엔트리에는 어떤 advanced id도 없다. +`verifyCleanArchitectureDependencies`가 build time에, `GrpcStableBuildInvariant`와 +`GrpcAdvancedModuleGuard.requireStableStarterIsClean`이 runtime에 같은 규칙을 강제한다. + +## Capability grade와 feature flag + +capability마다 별도 flag를 갖는다. 하나의 "advanced" 스위치로 묶지 않는 이유는, gRPC-Web을 켜는 +결정(프록시 하나)과 xDS를 켜는 결정(control plane과 그 장애 모드 전체)이 같은 결정이 아니기 +때문이다. 하나의 스위치는 두 번째 결정을 실수로 내리게 만든다. + +| grade | 시작 가능 | production 추가 승인 | +| --- | --- | --- | +| `ADVANCED_STABLE` | O | 불필요 | +| `EXPERIMENTAL` | O | **필요** | +| `WATCH` | X (추적만) | — | +| `DISABLED` | X | — | + +property key는 `ca-skeleton.grpc.advanced..enabled`이고 전부 기본 off다. +`GrpcAdvancedModuleGuard`가 세 조건(flag 미설정 / grade가 시작 불가 / production 승인 없음)을 +구분해서 거부하며, 세 경우의 조치가 다르므로 메시지도 다르다. + +## Leaf별 담당 capability + +| leaf | capability | +| --- | --- | +| `grpc-advanced-bootstrap` | capability grade, feature flag, module guard, capability별 promotion gate | +| `grpc-advanced-edition` | Protobuf Edition 2024 opt-in lane, Edition 2026 watch lane | +| `grpc-advanced-streaming` | client streaming(session/dedup/checkpoint), bidi(dual sequence), manual flow control | +| `grpc-advanced-resilience` | read-only hedging, custom name resolver SPI, custom load balancer SPI, proxyless xDS | +| `grpc-advanced-compat` | gRPC-Web, Servlet HTTP/2, Spring Integration bridge, Reactor adapter, Kotlin 경계 | +| `grpc-advanced-diagnostics` | Channelz/CSDS 진단, advanced infrastructure testkit 요구사항 | + +## Promotion은 capability별로 독립이다 + +`GrpcAdvancedPromotionEvidence`는 capability마다 별도 레코드다. 공유 레코드였다면 하나를 승격할 때 +같은 시점에 측정된 다른 것들이 함께 승격된다. `GrpcAdvancedPromotionGate.capabilitiesDraggedAlong`이 +항상 빈 리스트인 것은 주석이 아니라 테스트되는 속성이다. + +승격 문턱은 두 개다: `ADVANCED_STABLE`은 7일 soak + 전체 증거, Stable default는 30일 soak. 두 번째가 +더 높은 이유는 모든 배포가 그 의존성과 장애 모드를 갖게 되기 때문이다. + +## 이 저장소에서 검증할 수 없는 것 + +`GrpcAdvancedInfrastructureTestkit`이 capability별로 필요한 실제 인프라를 명시한다. + +- `grpc-web` → gRPC-Web 프록시 +- `servlet-compat` → Servlet 컨테이너 +- `xds` → 중지 가능한 xDS control plane +- `kotlin` → Kotlin 툴체인 (**이 저장소에 없다**) + +인프라 없이 도는 suite는 통과하면서 아무것도 증명하지 않으므로, suite가 없는 것보다 나쁘다. +`GrpcKotlinCompatibilityGate.supportableHere()`가 `false`를 반환하는 것은 그 사실의 코드 표현이다 — +Kotlin 계약 요구사항 4개는 검증되지만 compile lane은 존재하지 않는다. + +## 금지 + +- Stable leaf(`grpc:*`)가 이 family를 참조하는 것. +- capability grade 없이, 또는 flag 없이 advanced 코드 경로를 실행하는 것. +- `WATCH` capability를 스키마 소스로 사용하는 것 (`GrpcEdition2026Guard`가 무조건 거부한다). +- 실제 인프라 없이 실행한 suite를 promotion evidence로 인용하는 것. diff --git a/src/grpc-advanced/grpc-advanced-bootstrap/build.gradle b/src/grpc-advanced/grpc-advanced-bootstrap/build.gradle new file mode 100644 index 00000000..55ed3e2f --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-bootstrap/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'java-library' + +// The Advanced boundary itself: capability grades, the `ca-skeleton.grpc.advanced.*` feature-flag +// contract, the module guard that refuses an unflagged capability, and the per-capability +// promotion gate. +// +// This leaf depends on Stable public types and never the other way round. The Stable starter's +// registry entry names no advanced id, so `verifyCleanArchitectureDependencies` is what makes +// "Advanced never leaks into Stable" a build failure rather than a review note. +dependencies { + api project(':grpc:grpc-core-api') +} diff --git a/src/grpc-advanced/grpc-advanced-bootstrap/gradle.lockfile b/src/grpc-advanced/grpc-advanced-bootstrap/gradle.lockfile new file mode 100644 index 00000000..e2c95854 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-bootstrap/gradle.lockfile @@ -0,0 +1,84 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty=compileClasspath,runtimeClasspath diff --git a/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcAdvancedCapability.java b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcAdvancedCapability.java new file mode 100644 index 00000000..b679f14e --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcAdvancedCapability.java @@ -0,0 +1,65 @@ +package dev.caskeleton.grpc.advanced.bootstrap; + +/** + * Every capability the Stable platform deliberately excludes, and how ready each one is. + * + *

Grading them individually is the design. Bundling them under one "advanced" flag makes + * enabling gRPC-Web — a compatibility bridge with a proxy in front of it — the same decision as + * enabling xDS, which brings a control plane and its outage modes. They are not the same decision, + * and a single switch is how the second one gets made by accident. + */ +public enum GrpcAdvancedCapability { + /** Protobuf Edition 2024 as an opt-in schema lane. */ + EDITION_2024("edition-2024", GrpcCapabilityGrade.ADVANCED_STABLE), + /** Protobuf Edition 2026. Recorded, not usable. */ + EDITION_2026("edition-2026", GrpcCapabilityGrade.WATCH), + /** Client streaming with session, dedup and checkpoint. */ + CLIENT_STREAMING("client-streaming", GrpcCapabilityGrade.ADVANCED_STABLE), + /** Bidirectional streaming with independent per-direction sequences. */ + BIDI_STREAMING("bidi-streaming", GrpcCapabilityGrade.ADVANCED_STABLE), + /** Manual flow control, for approved streaming methods. */ + MANUAL_FLOW_CONTROL("manual-flow-control", GrpcCapabilityGrade.ADVANCED_STABLE), + /** Read-only unary hedging. */ + HEDGING("hedging", GrpcCapabilityGrade.EXPERIMENTAL), + /** A custom name resolver. */ + CUSTOM_RESOLVER("custom-resolver", GrpcCapabilityGrade.ADVANCED_STABLE), + /** A custom load balancer. */ + CUSTOM_LOAD_BALANCER("custom-load-balancer", GrpcCapabilityGrade.EXPERIMENTAL), + /** Proxyless xDS. */ + XDS("xds", GrpcCapabilityGrade.EXPERIMENTAL), + /** The gRPC-Web bridge. */ + GRPC_WEB("grpc-web", GrpcCapabilityGrade.ADVANCED_STABLE), + /** A Servlet container owning the HTTP/2 socket. */ + SERVLET_COMPAT("servlet-compat", GrpcCapabilityGrade.ADVANCED_STABLE), + /** The Spring Integration bridge. */ + INTEGRATION_BRIDGE("integration-bridge", GrpcCapabilityGrade.ADVANCED_STABLE), + /** The Reactor adapter. */ + REACTOR("reactor", GrpcCapabilityGrade.ADVANCED_STABLE), + /** The Kotlin coroutine and Flow adapter. */ + KOTLIN("kotlin", GrpcCapabilityGrade.ADVANCED_STABLE), + /** Channelz and CSDS diagnostics. */ + CHANNEL_DIAGNOSTICS("channel-diagnostics", GrpcCapabilityGrade.ADVANCED_STABLE); + + private final String flagName; + private final GrpcCapabilityGrade defaultGrade; + + GrpcAdvancedCapability(String flagName, GrpcCapabilityGrade defaultGrade) { + this.flagName = flagName; + this.defaultGrade = defaultGrade; + } + + /** The property suffix under {@code ca-skeleton.grpc.advanced}. */ + public String flagName() { + return flagName; + } + + /** The full property key that enables this capability. */ + public String propertyKey() { + return "ca-skeleton.grpc.advanced." + flagName + ".enabled"; + } + + /** How ready this capability is today. */ + public GrpcCapabilityGrade defaultGrade() { + return defaultGrade; + } +} diff --git a/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcAdvancedCapabilityDisabledException.java b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcAdvancedCapabilityDisabledException.java new file mode 100644 index 00000000..8a5eb019 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcAdvancedCapabilityDisabledException.java @@ -0,0 +1,42 @@ +package dev.caskeleton.grpc.advanced.bootstrap; + +/** + * A capability was used without being enabled. + * + *

The message carries the property key. An advanced capability is off by default and the refusal + * is the first thing a developer meets when trying it; telling them which key to set turns a + * support question into a configuration line. + */ +public class GrpcAdvancedCapabilityDisabledException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient GrpcAdvancedCapability capability; + + /** Refuses use of a disabled capability. */ + public GrpcAdvancedCapabilityDisabledException(GrpcAdvancedCapability capability, String reason) { + super(render(capability, reason)); + this.capability = capability; + } + + private static String render(GrpcAdvancedCapability capability, String reason) { + if (capability == null) { + throw new IllegalArgumentException("a capability is required"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("a refusal explains itself"); + } + return "advanced capability '" + + capability.flagName() + + "' is not available: " + + reason + + " (set " + + capability.propertyKey() + + "=true to enable it)"; + } + + /** The capability that was refused. */ + public GrpcAdvancedCapability capability() { + return capability; + } +} diff --git a/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcAdvancedFeatureFlags.java b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcAdvancedFeatureFlags.java new file mode 100644 index 00000000..911063e7 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcAdvancedFeatureFlags.java @@ -0,0 +1,105 @@ +package dev.caskeleton.grpc.advanced.bootstrap; + +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; + +/** + * Which advanced capabilities a deployment has turned on. + * + *

Everything is off unless named. A capability that switches itself on because its jar is + * present is a capability nobody decided to run, and the ones here bring proxies, control planes + * and duplicate request load with them. + */ +public final class GrpcAdvancedFeatureFlags { + + private final Map enabled = + new EnumMap<>(GrpcAdvancedCapability.class); + private final Map grades = + new EnumMap<>(GrpcAdvancedCapability.class); + private final boolean production; + private final Set productionApprovals; + + private GrpcAdvancedFeatureFlags( + boolean production, Set productionApprovals) { + this.production = production; + this.productionApprovals = + EnumSet.copyOf( + productionApprovals.isEmpty() + ? EnumSet.noneOf(GrpcAdvancedCapability.class) + : EnumSet.copyOf(productionApprovals)); + for (GrpcAdvancedCapability capability : GrpcAdvancedCapability.values()) { + enabled.put(capability, false); + grades.put(capability, capability.defaultGrade()); + } + } + + /** Flags for a non-production environment. */ + public static GrpcAdvancedFeatureFlags forDevelopment() { + return new GrpcAdvancedFeatureFlags(false, Set.of()); + } + + /** + * Flags for production. + * + * @param productionApprovals the experimental capabilities somebody has accepted the risk of + */ + public static GrpcAdvancedFeatureFlags forProduction( + Set productionApprovals) { + if (productionApprovals == null) { + throw new IllegalArgumentException("an approval set is required, even if empty"); + } + return new GrpcAdvancedFeatureFlags(true, productionApprovals); + } + + /** Turns a capability on. */ + public GrpcAdvancedFeatureFlags enable(GrpcAdvancedCapability capability) { + if (capability == null) { + throw new IllegalArgumentException("a capability is required"); + } + enabled.put(capability, true); + return this; + } + + /** Overrides a capability's grade, for a deployment that has its own evidence. */ + public GrpcAdvancedFeatureFlags withGrade( + GrpcAdvancedCapability capability, GrpcCapabilityGrade grade) { + if (capability == null || grade == null) { + throw new IllegalArgumentException("a grade override needs both parts"); + } + grades.put(capability, grade); + return this; + } + + /** Whether the flag is set, regardless of whether the capability may actually start. */ + public boolean flagSet(GrpcAdvancedCapability capability) { + return Boolean.TRUE.equals(enabled.get(capability)); + } + + /** The grade in force for a capability. */ + public GrpcCapabilityGrade gradeOf(GrpcAdvancedCapability capability) { + return grades.get(capability); + } + + /** Whether this deployment is production. */ + public boolean production() { + return production; + } + + /** Whether an experimental capability has been separately approved for production. */ + public boolean productionApproved(GrpcAdvancedCapability capability) { + return productionApprovals.contains(capability); + } + + /** The capabilities that are both flagged and permitted to start. */ + public Set active() { + Set running = EnumSet.noneOf(GrpcAdvancedCapability.class); + for (GrpcAdvancedCapability capability : GrpcAdvancedCapability.values()) { + if (GrpcAdvancedModuleGuard.available(this, capability)) { + running.add(capability); + } + } + return Set.copyOf(running); + } +} diff --git a/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcAdvancedModuleGuard.java b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcAdvancedModuleGuard.java new file mode 100644 index 00000000..4a510f4c --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcAdvancedModuleGuard.java @@ -0,0 +1,84 @@ +package dev.caskeleton.grpc.advanced.bootstrap; + +import dev.caskeleton.grpc.core.GrpcStableBuildInvariant; +import dev.caskeleton.grpc.core.GrpcStableModuleCatalog; +import java.util.Set; + +/** + * The single gate every advanced capability passes through. + * + *

Three conditions, checked in this order because each explains a different refusal: the flag is + * not set, the grade cannot start at all, or production has not separately approved an experimental + * capability. Collapsing them into one boolean produces a "not enabled" message for three + * situations with three different remedies. + */ +public final class GrpcAdvancedModuleGuard { + + private GrpcAdvancedModuleGuard() {} + + /** Whether {@code capability} may run under {@code flags}. */ + public static boolean available( + GrpcAdvancedFeatureFlags flags, GrpcAdvancedCapability capability) { + if (flags == null || capability == null) { + throw new IllegalArgumentException("availability needs flags and a capability"); + } + if (!flags.flagSet(capability)) { + return false; + } + GrpcCapabilityGrade grade = flags.gradeOf(capability); + if (!grade.startable()) { + return false; + } + return !(flags.production() + && grade.requiresProductionApproval() + && !flags.productionApproved(capability)); + } + + /** + * Fails when {@code capability} may not run. + * + * @throws GrpcAdvancedCapabilityDisabledException naming which of the three conditions failed + */ + public static void require(GrpcAdvancedFeatureFlags flags, GrpcAdvancedCapability capability) { + if (flags == null || capability == null) { + throw new IllegalArgumentException("a guard needs flags and a capability"); + } + if (!flags.flagSet(capability)) { + throw new GrpcAdvancedCapabilityDisabledException(capability, "its feature flag is not set"); + } + GrpcCapabilityGrade grade = flags.gradeOf(capability); + if (!grade.startable()) { + throw new GrpcAdvancedCapabilityDisabledException( + capability, + "it is graded " + + grade + + ", which cannot start; a WATCH capability is tracked rather than implemented"); + } + if (flags.production() + && grade.requiresProductionApproval() + && !flags.productionApproved(capability)) { + throw new GrpcAdvancedCapabilityDisabledException( + capability, + "it is " + + grade + + " and production needs a separate approval; the flag says somebody wanted it, not " + + "that somebody accepted its uncharacterised failure modes"); + } + } + + /** + * Fails when the Stable starter's dependency set reaches an advanced module. + * + *

The same invariant the registry enforces at build time, asserted here so a runtime that was + * assembled some other way — a fat jar, a shaded artifact, a test harness — is checked too. + */ + public static void requireStableStarterIsClean(Set starterDependencies) { + GrpcStableBuildInvariant.requireNoAdvancedDependency( + "grpc-spring-boot-starter", starterDependencies); + } + + /** The advanced module ids, for a runtime classpath check. */ + public static Set advancedModules() { + return GrpcStableModuleCatalog.advancedModules(); + } +} diff --git a/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcCapabilityGrade.java b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcCapabilityGrade.java new file mode 100644 index 00000000..d97574ce --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcCapabilityGrade.java @@ -0,0 +1,38 @@ +package dev.caskeleton.grpc.advanced.bootstrap; + +/** + * How much a capability has been established, and what that permits. + * + *

{@link #EXPERIMENTAL} in production needs a second, separate approval rather than the + * capability flag alone. The flag says somebody wanted the feature; the approval says somebody + * accepted that its failure modes are not fully characterised, which is a different person's + * decision on most teams. + */ +public enum GrpcCapabilityGrade { + /** Contract, fault and operational evidence exist. Enable with the capability flag. */ + ADVANCED_STABLE(true, false), + /** Works, but its failure modes are not fully characterised. Needs a production approval too. */ + EXPERIMENTAL(true, true), + /** Tracked, not implemented. Cannot be enabled. */ + WATCH(false, false), + /** Withdrawn or refused. Cannot be enabled. */ + DISABLED(false, false); + + private final boolean startable; + private final boolean requiresProductionApproval; + + GrpcCapabilityGrade(boolean startable, boolean requiresProductionApproval) { + this.startable = startable; + this.requiresProductionApproval = requiresProductionApproval; + } + + /** Whether a deployment may run this capability at all. */ + public boolean startable() { + return startable; + } + + /** Whether production additionally requires an explicit approval. */ + public boolean requiresProductionApproval() { + return requiresProductionApproval; + } +} diff --git a/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/release/GrpcAdvancedPromotionDecision.java b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/release/GrpcAdvancedPromotionDecision.java new file mode 100644 index 00000000..9d4ef31c --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/release/GrpcAdvancedPromotionDecision.java @@ -0,0 +1,50 @@ +package dev.caskeleton.grpc.advanced.release; + +import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability; +import dev.caskeleton.grpc.advanced.bootstrap.GrpcCapabilityGrade; +import java.util.List; + +/** + * Whether one capability moves to a new grade. + * + *

Carries the grade it would move to as well as the blockers, so a refusal says what was being + * asked for. "Not promoted" is ambiguous between a failed promotion to Advanced Stable and a failed + * promotion to a Stable default, and the second is a much larger decision. + */ +public record GrpcAdvancedPromotionDecision( + GrpcAdvancedCapability capability, + GrpcCapabilityGrade from, + GrpcCapabilityGrade to, + boolean promoted, + List blockers) { + + /** Requires blockers exactly when refused. */ + public GrpcAdvancedPromotionDecision { + if (capability == null || from == null || to == null || blockers == null) { + throw new IllegalArgumentException( + "a promotion decision names its capability and both grades"); + } + blockers = List.copyOf(blockers); + if (promoted && !blockers.isEmpty()) { + throw new IllegalArgumentException("a granted promotion has no blockers"); + } + if (!promoted && blockers.isEmpty()) { + throw new IllegalArgumentException("a refused promotion says why"); + } + } + + /** A granted promotion. */ + public static GrpcAdvancedPromotionDecision grant( + GrpcAdvancedCapability capability, GrpcCapabilityGrade from, GrpcCapabilityGrade to) { + return new GrpcAdvancedPromotionDecision(capability, from, to, true, List.of()); + } + + /** A refused promotion. */ + public static GrpcAdvancedPromotionDecision refuse( + GrpcAdvancedCapability capability, + GrpcCapabilityGrade from, + GrpcCapabilityGrade to, + List blockers) { + return new GrpcAdvancedPromotionDecision(capability, from, to, false, blockers); + } +} diff --git a/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/release/GrpcAdvancedPromotionEvidence.java b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/release/GrpcAdvancedPromotionEvidence.java new file mode 100644 index 00000000..f5a81c29 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/release/GrpcAdvancedPromotionEvidence.java @@ -0,0 +1,75 @@ +package dev.caskeleton.grpc.advanced.release; + +import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability; +import java.time.Duration; +import java.util.Set; + +/** + * What one capability has behind it. + * + *

Per capability, never shared. The Stable plan's requirement that Edition, streaming, xDS, + * gRPC-Web, Servlet and the language adapters do not gate each other only means something if their + * evidence is separate: a shared record makes promoting one of them promote whichever others + * happened to be measured at the same time. + */ +public record GrpcAdvancedPromotionEvidence( + GrpcAdvancedCapability capability, + boolean compatibilityEvidence, + boolean securityReview, + boolean faultEvidence, + boolean performanceEvidence, + Duration soakDuration, + boolean architectureDecisionRecord, + boolean runbook, + boolean realEnvironmentTest) { + + /** Requires a capability and a non-negative soak. */ + public GrpcAdvancedPromotionEvidence { + if (capability == null) { + throw new IllegalArgumentException("promotion evidence names its capability"); + } + if (soakDuration == null || soakDuration.isNegative()) { + throw new IllegalArgumentException("a soak duration must be present and non-negative"); + } + } + + /** No evidence at all, which is where a capability starts. */ + public static GrpcAdvancedPromotionEvidence none(GrpcAdvancedCapability capability) { + return new GrpcAdvancedPromotionEvidence( + capability, false, false, false, false, Duration.ZERO, false, false, false); + } + + /** Everything a promotion to Advanced Stable needs. */ + public static GrpcAdvancedPromotionEvidence complete( + GrpcAdvancedCapability capability, Duration soakDuration) { + return new GrpcAdvancedPromotionEvidence( + capability, true, true, true, true, soakDuration, true, true, true); + } + + /** Which required items are absent, as a set a report can print. */ + public Set missing() { + Set missing = new java.util.LinkedHashSet<>(); + if (!compatibilityEvidence) { + missing.add("compatibility evidence"); + } + if (!securityReview) { + missing.add("security review"); + } + if (!faultEvidence) { + missing.add("fault evidence"); + } + if (!performanceEvidence) { + missing.add("performance evidence"); + } + if (!architectureDecisionRecord) { + missing.add("architecture decision record"); + } + if (!runbook) { + missing.add("runbook"); + } + if (!realEnvironmentTest) { + missing.add("real environment test"); + } + return Set.copyOf(missing); + } +} diff --git a/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/release/GrpcAdvancedPromotionGate.java b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/release/GrpcAdvancedPromotionGate.java new file mode 100644 index 00000000..39475362 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/release/GrpcAdvancedPromotionGate.java @@ -0,0 +1,85 @@ +package dev.caskeleton.grpc.advanced.release; + +import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability; +import dev.caskeleton.grpc.advanced.bootstrap.GrpcCapabilityGrade; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * Decides one capability's promotion, on its own evidence. + * + *

Two thresholds rather than one. Reaching Advanced Stable means the capability works and is + * documented; becoming a Stable default means every deployment gets it, which additionally puts its + * dependencies on every classpath and its failure modes in every on-call rotation. The second needs + * the first plus a longer soak, because a capability that has run in one deployment for a week is + * not the same claim as one that ships to all of them. + */ +public final class GrpcAdvancedPromotionGate { + + /** The soak a promotion to Advanced Stable requires. */ + public static final Duration ADVANCED_STABLE_SOAK = Duration.ofDays(7); + + /** The soak a promotion to a Stable default requires. */ + public static final Duration STABLE_DEFAULT_SOAK = Duration.ofDays(30); + + private GrpcAdvancedPromotionGate() {} + + /** + * Whether {@code capability} may move from {@code from} to {@code to}. + * + * @throws IllegalArgumentException when the transition is not one this gate governs + */ + public static GrpcAdvancedPromotionDecision evaluate( + GrpcAdvancedPromotionEvidence evidence, GrpcCapabilityGrade from, GrpcCapabilityGrade to) { + if (evidence == null || from == null || to == null) { + throw new IllegalArgumentException("a promotion needs evidence and both grades"); + } + if (from == to) { + throw new IllegalArgumentException("a promotion changes the grade"); + } + GrpcAdvancedCapability capability = evidence.capability(); + List blockers = new ArrayList<>(); + + evidence.missing().stream() + .sorted() + .forEach(missing -> blockers.add(capability.flagName() + " has no " + missing)); + + Duration requiredSoak = + to == GrpcCapabilityGrade.ADVANCED_STABLE ? ADVANCED_STABLE_SOAK : STABLE_DEFAULT_SOAK; + if (evidence.soakDuration().compareTo(requiredSoak) < 0) { + blockers.add( + capability.flagName() + + " soaked for " + + evidence.soakDuration().toDays() + + " day(s); promotion to " + + to + + " requires " + + requiredSoak.toDays()); + } + if (from == GrpcCapabilityGrade.WATCH && to != GrpcCapabilityGrade.EXPERIMENTAL) { + blockers.add( + capability.flagName() + + " is WATCH, which is tracked rather than implemented; it becomes EXPERIMENTAL " + + "before anything else"); + } + return blockers.isEmpty() + ? GrpcAdvancedPromotionDecision.grant(capability, from, to) + : GrpcAdvancedPromotionDecision.refuse(capability, from, to, blockers); + } + + /** + * Whether promoting {@code promoted} would drag another capability with it. + * + *

Always empty, and the method exists so a test can assert that rather than a comment claiming + * it: each capability's evidence is its own record, so there is no path by which one promotion + * changes another's grade. + */ + public static List capabilitiesDraggedAlong( + GrpcAdvancedCapability promoted) { + if (promoted == null) { + throw new IllegalArgumentException("a capability is required"); + } + return List.of(); + } +} diff --git a/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/release/GrpcAdvancedSupportMatrix.java b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/release/GrpcAdvancedSupportMatrix.java new file mode 100644 index 00000000..568e3ed6 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-bootstrap/src/main/java/dev/caskeleton/grpc/advanced/release/GrpcAdvancedSupportMatrix.java @@ -0,0 +1,66 @@ +package dev.caskeleton.grpc.advanced.release; + +import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability; +import dev.caskeleton.grpc.advanced.bootstrap.GrpcCapabilityGrade; +import java.util.EnumMap; +import java.util.Map; + +/** + * Every advanced capability's current grade, in one place an adopter can read. + * + *

The published answer to "is this supported". Without it the answer is inferred from whether a + * class exists, which says only that somebody wrote it. + */ +public final class GrpcAdvancedSupportMatrix { + + private final Map grades = + new EnumMap<>(GrpcAdvancedCapability.class); + + /** A matrix at each capability's default grade. */ + public GrpcAdvancedSupportMatrix() { + for (GrpcAdvancedCapability capability : GrpcAdvancedCapability.values()) { + grades.put(capability, capability.defaultGrade()); + } + } + + /** The grade of one capability. */ + public GrpcCapabilityGrade gradeOf(GrpcAdvancedCapability capability) { + if (capability == null) { + throw new IllegalArgumentException("a capability is required"); + } + return grades.get(capability); + } + + /** + * Applies a granted promotion. + * + * @throws IllegalArgumentException when the decision's starting grade is not the current one, + * which means two promotions raced or one was replayed + */ + public GrpcAdvancedSupportMatrix apply(GrpcAdvancedPromotionDecision decision) { + if (decision == null) { + throw new IllegalArgumentException("a decision is required"); + } + if (!decision.promoted()) { + return this; + } + GrpcCapabilityGrade current = grades.get(decision.capability()); + if (current != decision.from()) { + throw new IllegalArgumentException( + "capability '" + + decision.capability().flagName() + + "' is " + + current + + ", not " + + decision.from() + + "; this decision was made against a different matrix"); + } + grades.put(decision.capability(), decision.to()); + return this; + } + + /** The whole matrix. */ + public Map snapshot() { + return Map.copyOf(grades); + } +} diff --git a/src/grpc-advanced/grpc-advanced-bootstrap/src/test/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcAdvancedModuleGuardTest.java b/src/grpc-advanced/grpc-advanced-bootstrap/src/test/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcAdvancedModuleGuardTest.java new file mode 100644 index 00000000..9f53e67f --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-bootstrap/src/test/java/dev/caskeleton/grpc/advanced/bootstrap/GrpcAdvancedModuleGuardTest.java @@ -0,0 +1,129 @@ +package dev.caskeleton.grpc.advanced.bootstrap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcAdvancedModuleGuardTest { + + @Test + @DisplayName("every advanced capability is off unless a deployment names it") + void everythingIsOffByDefault() { + GrpcAdvancedFeatureFlags flags = GrpcAdvancedFeatureFlags.forDevelopment(); + + assertThat(flags.active()).isEmpty(); + for (GrpcAdvancedCapability capability : GrpcAdvancedCapability.values()) { + assertThat(GrpcAdvancedModuleGuard.available(flags, capability)).isFalse(); + } + } + + @Test + @DisplayName("each capability has its own flag, not a shared one") + void eachCapabilityHasItsOwnFlag() { + assertThat(GrpcAdvancedCapability.XDS.propertyKey()) + .isEqualTo("ca-skeleton.grpc.advanced.xds.enabled"); + assertThat(GrpcAdvancedCapability.GRPC_WEB.propertyKey()) + .isNotEqualTo(GrpcAdvancedCapability.XDS.propertyKey()); + + GrpcAdvancedFeatureFlags flags = + GrpcAdvancedFeatureFlags.forDevelopment().enable(GrpcAdvancedCapability.GRPC_WEB); + + assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.GRPC_WEB)).isTrue(); + assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.XDS)).isFalse(); + } + + @Test + @DisplayName("an unflagged capability is refused, and the message names the key to set") + void anUnflaggedCapabilityNamesItsKey() { + assertThatThrownBy( + () -> + GrpcAdvancedModuleGuard.require( + GrpcAdvancedFeatureFlags.forDevelopment(), GrpcAdvancedCapability.REACTOR)) + .isInstanceOf(GrpcAdvancedCapabilityDisabledException.class) + .hasMessageContaining("ca-skeleton.grpc.advanced.reactor.enabled=true"); + } + + @Test + @DisplayName("a WATCH capability cannot be enabled, however loudly it is flagged") + void aWatchCapabilityCannotStart() { + GrpcAdvancedFeatureFlags flags = + GrpcAdvancedFeatureFlags.forDevelopment().enable(GrpcAdvancedCapability.EDITION_2026); + + assertThat(GrpcAdvancedCapability.EDITION_2026.defaultGrade()) + .isEqualTo(GrpcCapabilityGrade.WATCH); + assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.EDITION_2026)) + .isFalse(); + assertThatThrownBy( + () -> GrpcAdvancedModuleGuard.require(flags, GrpcAdvancedCapability.EDITION_2026)) + .isInstanceOf(GrpcAdvancedCapabilityDisabledException.class) + .hasMessageContaining("tracked rather than implemented"); + } + + @Test + @DisplayName("an experimental capability needs a second approval in production") + void experimentalCapabilitiesNeedAProductionApproval() { + GrpcAdvancedFeatureFlags unapproved = + GrpcAdvancedFeatureFlags.forProduction(Set.of()).enable(GrpcAdvancedCapability.XDS); + GrpcAdvancedFeatureFlags approved = + GrpcAdvancedFeatureFlags.forProduction(Set.of(GrpcAdvancedCapability.XDS)) + .enable(GrpcAdvancedCapability.XDS); + + assertThat(GrpcAdvancedModuleGuard.available(unapproved, GrpcAdvancedCapability.XDS)).isFalse(); + assertThat(GrpcAdvancedModuleGuard.available(approved, GrpcAdvancedCapability.XDS)).isTrue(); + assertThatThrownBy( + () -> GrpcAdvancedModuleGuard.require(unapproved, GrpcAdvancedCapability.XDS)) + .isInstanceOf(GrpcAdvancedCapabilityDisabledException.class) + .hasMessageContaining("uncharacterised failure modes"); + } + + @Test + @DisplayName("an experimental capability runs outside production on its flag alone") + void experimentalCapabilitiesRunInDevelopment() { + GrpcAdvancedFeatureFlags flags = + GrpcAdvancedFeatureFlags.forDevelopment().enable(GrpcAdvancedCapability.HEDGING); + + assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.HEDGING)).isTrue(); + GrpcAdvancedModuleGuard.require(flags, GrpcAdvancedCapability.HEDGING); + } + + @Test + @DisplayName("the Stable starter reaching an advanced module is refused") + void theStableStarterMayNotReachAnAdvancedModule() { + GrpcAdvancedModuleGuard.requireStableStarterIsClean(Set.of("grpc-core-api", "grpc-policy")); + + assertThatThrownBy( + () -> + GrpcAdvancedModuleGuard.requireStableStarterIsClean( + Set.of("grpc-core-api", "grpc-advanced-resilience"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("grpc-advanced-resilience"); + assertThat(GrpcAdvancedModuleGuard.advancedModules()) + .contains("grpc-advanced-bootstrap", "grpc-advanced-streaming", "grpc-advanced-compat"); + } + + @Test + @DisplayName("only flagged and permitted capabilities appear as active") + void activeReflectsBothFlagAndGrade() { + GrpcAdvancedFeatureFlags flags = + GrpcAdvancedFeatureFlags.forProduction(Set.of()) + .enable(GrpcAdvancedCapability.GRPC_WEB) + .enable(GrpcAdvancedCapability.XDS) + .enable(GrpcAdvancedCapability.EDITION_2026); + + assertThat(flags.active()).containsExactly(GrpcAdvancedCapability.GRPC_WEB); + } + + @Test + @DisplayName("a deployment may raise a capability's grade on its own evidence") + void aDeploymentMayOverrideAGrade() { + GrpcAdvancedFeatureFlags flags = + GrpcAdvancedFeatureFlags.forProduction(Set.of()) + .enable(GrpcAdvancedCapability.HEDGING) + .withGrade(GrpcAdvancedCapability.HEDGING, GrpcCapabilityGrade.ADVANCED_STABLE); + + assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.HEDGING)).isTrue(); + } +} diff --git a/src/grpc-advanced/grpc-advanced-bootstrap/src/test/java/dev/caskeleton/grpc/advanced/release/GrpcAdvancedPromotionGateTest.java b/src/grpc-advanced/grpc-advanced-bootstrap/src/test/java/dev/caskeleton/grpc/advanced/release/GrpcAdvancedPromotionGateTest.java new file mode 100644 index 00000000..ec80715e --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-bootstrap/src/test/java/dev/caskeleton/grpc/advanced/release/GrpcAdvancedPromotionGateTest.java @@ -0,0 +1,150 @@ +package dev.caskeleton.grpc.advanced.release; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability; +import dev.caskeleton.grpc.advanced.bootstrap.GrpcCapabilityGrade; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcAdvancedPromotionGateTest { + + @Test + @DisplayName("a capability with complete evidence and a full soak is promoted") + void completeEvidencePromotes() { + GrpcAdvancedPromotionDecision decision = + GrpcAdvancedPromotionGate.evaluate( + GrpcAdvancedPromotionEvidence.complete( + GrpcAdvancedCapability.HEDGING, Duration.ofDays(7)), + GrpcCapabilityGrade.EXPERIMENTAL, + GrpcCapabilityGrade.ADVANCED_STABLE); + + assertThat(decision.promoted()).isTrue(); + assertThat(decision.blockers()).isEmpty(); + } + + @Test + @DisplayName("every missing piece of evidence is named") + void everyMissingPieceIsNamed() { + GrpcAdvancedPromotionDecision decision = + GrpcAdvancedPromotionGate.evaluate( + GrpcAdvancedPromotionEvidence.none(GrpcAdvancedCapability.XDS), + GrpcCapabilityGrade.EXPERIMENTAL, + GrpcCapabilityGrade.ADVANCED_STABLE); + + assertThat(decision.blockers()) + .anySatisfy(blocker -> assertThat(blocker).contains("compatibility evidence")) + .anySatisfy(blocker -> assertThat(blocker).contains("security review")) + .anySatisfy(blocker -> assertThat(blocker).contains("fault evidence")) + .anySatisfy(blocker -> assertThat(blocker).contains("runbook")) + .anySatisfy(blocker -> assertThat(blocker).contains("real environment test")) + .anySatisfy(blocker -> assertThat(blocker).contains("soaked for")); + } + + @Test + @DisplayName("becoming a Stable default needs a longer soak than becoming Advanced Stable") + void theStableDefaultThresholdIsHigher() { + GrpcAdvancedPromotionEvidence weekLongSoak = + GrpcAdvancedPromotionEvidence.complete(GrpcAdvancedCapability.GRPC_WEB, Duration.ofDays(7)); + + assertThat( + GrpcAdvancedPromotionGate.evaluate( + weekLongSoak, + GrpcCapabilityGrade.EXPERIMENTAL, + GrpcCapabilityGrade.ADVANCED_STABLE) + .promoted()) + .isTrue(); + assertThat( + GrpcAdvancedPromotionGate.evaluate( + weekLongSoak, GrpcCapabilityGrade.ADVANCED_STABLE, GrpcCapabilityGrade.DISABLED) + .blockers()) + .anySatisfy(blocker -> assertThat(blocker).contains("requires 30")); + } + + @Test + @DisplayName("a WATCH capability becomes EXPERIMENTAL before anything else") + void watchPromotesOnlyToExperimental() { + assertThat( + GrpcAdvancedPromotionGate.evaluate( + GrpcAdvancedPromotionEvidence.complete( + GrpcAdvancedCapability.EDITION_2026, Duration.ofDays(60)), + GrpcCapabilityGrade.WATCH, + GrpcCapabilityGrade.ADVANCED_STABLE) + .blockers()) + .anySatisfy(blocker -> assertThat(blocker).contains("becomes EXPERIMENTAL")); + assertThat( + GrpcAdvancedPromotionGate.evaluate( + GrpcAdvancedPromotionEvidence.complete( + GrpcAdvancedCapability.EDITION_2026, Duration.ofDays(60)), + GrpcCapabilityGrade.WATCH, + GrpcCapabilityGrade.EXPERIMENTAL) + .promoted()) + .isTrue(); + } + + @Test + @DisplayName("promoting one capability drags none of the others with it") + void promotionsAreIndependent() { + GrpcAdvancedSupportMatrix matrix = new GrpcAdvancedSupportMatrix(); + GrpcCapabilityGrade webBefore = matrix.gradeOf(GrpcAdvancedCapability.GRPC_WEB); + + matrix.apply( + GrpcAdvancedPromotionGate.evaluate( + GrpcAdvancedPromotionEvidence.complete( + GrpcAdvancedCapability.HEDGING, Duration.ofDays(7)), + GrpcCapabilityGrade.EXPERIMENTAL, + GrpcCapabilityGrade.ADVANCED_STABLE)); + + assertThat(matrix.gradeOf(GrpcAdvancedCapability.HEDGING)) + .isEqualTo(GrpcCapabilityGrade.ADVANCED_STABLE); + assertThat(matrix.gradeOf(GrpcAdvancedCapability.GRPC_WEB)).isEqualTo(webBefore); + assertThat(GrpcAdvancedPromotionGate.capabilitiesDraggedAlong(GrpcAdvancedCapability.HEDGING)) + .isEmpty(); + } + + @Test + @DisplayName("a decision made against a different matrix state is refused") + void aStaleDecisionIsRefused() { + GrpcAdvancedSupportMatrix matrix = new GrpcAdvancedSupportMatrix(); + + assertThatThrownBy( + () -> + matrix.apply( + GrpcAdvancedPromotionGate.evaluate( + GrpcAdvancedPromotionEvidence.complete( + GrpcAdvancedCapability.GRPC_WEB, Duration.ofDays(7)), + GrpcCapabilityGrade.EXPERIMENTAL, + GrpcCapabilityGrade.ADVANCED_STABLE))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("made against a different matrix"); + } + + @Test + @DisplayName("a refused promotion leaves the matrix alone") + void aRefusedPromotionChangesNothing() { + GrpcAdvancedSupportMatrix matrix = new GrpcAdvancedSupportMatrix(); + + matrix.apply( + GrpcAdvancedPromotionGate.evaluate( + GrpcAdvancedPromotionEvidence.none(GrpcAdvancedCapability.XDS), + GrpcCapabilityGrade.EXPERIMENTAL, + GrpcCapabilityGrade.ADVANCED_STABLE)); + + assertThat(matrix.gradeOf(GrpcAdvancedCapability.XDS)) + .isEqualTo(GrpcCapabilityGrade.EXPERIMENTAL); + } + + @Test + @DisplayName("a promotion that changes nothing is refused") + void aNoOpPromotionIsRefused() { + assertThatThrownBy( + () -> + GrpcAdvancedPromotionGate.evaluate( + GrpcAdvancedPromotionEvidence.none(GrpcAdvancedCapability.XDS), + GrpcCapabilityGrade.EXPERIMENTAL, + GrpcCapabilityGrade.EXPERIMENTAL)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/build.gradle b/src/grpc-advanced/grpc-advanced-compat/build.gradle new file mode 100644 index 00000000..c4314270 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/build.gradle @@ -0,0 +1,23 @@ +apply plugin: 'java-library' + +// Compatibility bridges: gRPC-Web, the Servlet HTTP/2 profile, the Spring Integration bridge, the +// Reactor adapter, and the Kotlin coroutine/Flow boundary. +// +// No Kotlin source set (adaptation D7): this repository has no Kotlin toolchain, so the Kotlin lane +// is expressed as a Java-side boundary contract whose compatibility gate fails closed until a real +// toolchain lane exists. Everything the gate would otherwise assert — one schema source, coroutine +// cancellation propagation, Flow backpressure inside the Stable buffer limits, evidence type +// preservation — is a checkable contract without it. +dependencies { + api project(':grpc:grpc-core-api') + api project(':grpc:grpc-policy') + api project(':grpc:grpc-server') + api project(':grpc:grpc-client') + api project(':grpc-advanced:grpc-advanced-bootstrap') + + // api: the Reactor adapter's public signatures are Mono/Flux, and the Integration gateways name + // Spring Integration's Message. Hiding either would only stop an adopter compiling against the + // API this module documents. + api 'io.projectreactor:reactor-core' + api 'org.springframework.integration:spring-integration-core' +} diff --git a/src/grpc-advanced/grpc-advanced-compat/gradle.lockfile b/src/grpc-advanced/grpc-advanced-compat/gradle.lockfile new file mode 100644 index 00000000..1c1cd35f --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/gradle.lockfile @@ -0,0 +1,104 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.integration:spring-integration-core:7.0.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-messaging:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty= diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/integration/GrpcIntegrationBridgePolicy.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/integration/GrpcIntegrationBridgePolicy.java new file mode 100644 index 00000000..c13e0337 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/integration/GrpcIntegrationBridgePolicy.java @@ -0,0 +1,78 @@ +package dev.caskeleton.grpc.advanced.integration; + +import dev.caskeleton.grpc.context.GrpcMetadataKey; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * What a Spring Integration flow may exchange with a gRPC call. + * + *

The header allowlist is the whole policy. A Spring Integration {@code Message} accumulates + * headers as it moves through a flow — routing keys, correlation ids, errors channels, whatever a + * transformer added — and copying them onto gRPC metadata sends a service's internal plumbing + * across the network, where it counts against the metadata budget and occasionally carries + * something sensitive. + * + *

The bridge does not add durability. Spring Integration channels can look like a broker, and a + * bridge that implied acknowledgement or redelivery semantics would be promising something gRPC + * does not do. + */ +public record GrpcIntegrationBridgePolicy( + Set headerAllowlist, Set payloadConverters) { + + /** Copies both sets and refuses a bridge with no converter. */ + public GrpcIntegrationBridgePolicy { + if (headerAllowlist == null || payloadConverters == null) { + throw new IllegalArgumentException("a bridge policy states its allowlist and converters"); + } + headerAllowlist = Set.copyOf(headerAllowlist); + payloadConverters = Set.copyOf(payloadConverters); + if (payloadConverters.isEmpty()) { + throw new IllegalArgumentException( + "a bridge with no registered converter cannot turn a Message payload into a request; " + + "leaving it to reflection is how an unexpected type reaches the wire"); + } + } + + /** The metadata that survives from a Message's headers. */ + public Map metadataFrom(Map messageHeaders) { + if (messageHeaders == null) { + throw new IllegalArgumentException("message headers must not be null"); + } + Map metadata = new LinkedHashMap<>(); + headerAllowlist.forEach( + key -> { + Object value = messageHeaders.get(key.name()); + if (value != null) { + metadata.put(key, String.valueOf(value)); + } + }); + return Map.copyOf(metadata); + } + + /** Whether a payload type has a registered converter. */ + public boolean converterRegistered(String payloadType) { + return payloadConverters.contains(payloadType); + } + + /** + * Whether the bridge provides broker-style acknowledgement or redelivery. + * + *

Always false. A bridge that implied either would be promising a delivery guarantee gRPC does + * not make. + */ + public boolean providesBrokerSemantics() { + return false; + } + + /** + * Whether the bridge replaces the generated stub and service APIs. + * + *

Always false. It is one way to reach a gRPC call from an existing integration flow, not the + * way an application is meant to call one. + */ + public boolean replacesGeneratedApis() { + return false; + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/integration/GrpcIntegrationInboundGateway.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/integration/GrpcIntegrationInboundGateway.java new file mode 100644 index 00000000..0f3053c6 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/integration/GrpcIntegrationInboundGateway.java @@ -0,0 +1,58 @@ +package dev.caskeleton.grpc.advanced.integration; + +import dev.caskeleton.grpc.context.GrpcRequestContext; +import java.util.Map; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +/** + * Turns an inbound gRPC call into a Spring Integration {@code Message}. + * + *

The request context travels as one header holding the immutable context object, rather than as + * a scattering of actor, tenant and deadline headers. Flattening it would let a transformer in the + * middle of a flow change the tenant of a request that has already been authenticated. + * + * @param the request payload type + */ +public final class GrpcIntegrationInboundGateway { + + /** The header the immutable request context travels under. */ + public static final String CONTEXT_HEADER = "grpcRequestContext"; + + private final GrpcIntegrationBridgePolicy policy; + + /** Binds a gateway to its bridge policy. */ + public GrpcIntegrationInboundGateway(GrpcIntegrationBridgePolicy policy) { + if (policy == null) { + throw new IllegalArgumentException("an inbound gateway needs a bridge policy"); + } + this.policy = policy; + } + + /** + * Builds the Message a flow receives. + * + * @throws IllegalArgumentException when the payload type has no registered converter + */ + public Message toMessage(Q payload, GrpcRequestContext context) { + if (payload == null || context == null) { + throw new IllegalArgumentException("an inbound message needs a payload and a context"); + } + String payloadType = payload.getClass().getName(); + if (!policy.converterRegistered(payloadType)) { + throw new IllegalArgumentException( + "no converter is registered for '" + + payloadType + + "'; converting by reflection is how an unexpected type reaches a flow"); + } + Map headers = new java.util.LinkedHashMap<>(); + headers.put(CONTEXT_HEADER, context); + context.metadata().forEach((key, value) -> headers.put(key.name(), value)); + return MessageBuilder.withPayload(payload).copyHeaders(headers).build(); + } + + /** The policy in force. */ + public GrpcIntegrationBridgePolicy policy() { + return policy; + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/integration/GrpcIntegrationOutboundGateway.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/integration/GrpcIntegrationOutboundGateway.java new file mode 100644 index 00000000..82731ad0 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/integration/GrpcIntegrationOutboundGateway.java @@ -0,0 +1,57 @@ +package dev.caskeleton.grpc.advanced.integration; + +import dev.caskeleton.grpc.context.GrpcMetadataKey; +import java.util.Map; +import org.springframework.messaging.Message; + +/** + * Turns an outbound Spring Integration {@code Message} into a gRPC call's inputs. + * + *

Applies the header allowlist rather than copying what the flow accumulated. A flow's headers + * are its own bookkeeping; putting them on the wire spends the metadata budget on another service's + * plumbing and occasionally sends something that should not leave the process. + * + * @param the request payload type + */ +public final class GrpcIntegrationOutboundGateway { + + private final GrpcIntegrationBridgePolicy policy; + + /** Binds a gateway to its bridge policy. */ + public GrpcIntegrationOutboundGateway(GrpcIntegrationBridgePolicy policy) { + if (policy == null) { + throw new IllegalArgumentException("an outbound gateway needs a bridge policy"); + } + this.policy = policy; + } + + /** The metadata this message contributes to the call. */ + public Map metadataFrom(Message message) { + if (message == null) { + throw new IllegalArgumentException("an outbound message is required"); + } + return policy.metadataFrom(message.getHeaders()); + } + + /** + * The payload to send. + * + * @throws IllegalArgumentException when the payload type has no registered converter + */ + public C payloadFrom(Message message) { + if (message == null) { + throw new IllegalArgumentException("an outbound message is required"); + } + C payload = message.getPayload(); + String payloadType = payload.getClass().getName(); + if (!policy.converterRegistered(payloadType)) { + throw new IllegalArgumentException("no converter is registered for '" + payloadType + "'"); + } + return payload; + } + + /** The policy in force. */ + public GrpcIntegrationBridgePolicy policy() { + return policy; + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/kotlin/GrpcCoroutineContextBridge.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/kotlin/GrpcCoroutineContextBridge.java new file mode 100644 index 00000000..47086224 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/kotlin/GrpcCoroutineContextBridge.java @@ -0,0 +1,60 @@ +package dev.caskeleton.grpc.advanced.kotlin; + +import dev.caskeleton.grpc.deadline.GrpcCancellationCoordinator; +import dev.caskeleton.grpc.deadline.GrpcCancellationReason; +import java.time.Instant; +import java.util.function.Supplier; + +/** + * The contract a Kotlin coroutine adapter has to satisfy, expressed as Java callbacks. + * + *

Callbacks rather than coroutine types, so the rule is checkable without a Kotlin toolchain. A + * Kotlin adapter wires its {@code Job} completion handler to {@link #onCoroutineCancelled} and its + * cancellation source to {@link #cancelCoroutineScope}; what the platform needs is that both + * directions exist, and that is what this makes assertable. + */ +public final class GrpcCoroutineContextBridge { + + private GrpcCoroutineContextBridge() {} + + /** + * What a Kotlin adapter calls when its coroutine scope is cancelled. + * + *

Coroutine cancellation is cooperative and structured: cancelling a scope cancels its + * children, and a gRPC call started inside it is not one of them unless something says so. + */ + public static Runnable onCoroutineCancelled( + GrpcCancellationCoordinator coordinator, Supplier now) { + if (coordinator == null || now == null) { + throw new IllegalArgumentException("the bridge needs a coordinator and a clock"); + } + return () -> coordinator.cancel(GrpcCancellationReason.CLIENT_CANCELLED, now.get()); + } + + /** + * Registers the coroutine scope so a platform cancellation reaches it. + * + * @param cancelScope what the Kotlin side does to cancel its scope + */ + public static void cancelCoroutineScope( + GrpcCancellationCoordinator coordinator, Runnable cancelScope, String operationName) { + if (coordinator == null || cancelScope == null) { + throw new IllegalArgumentException("the bridge needs a coordinator and a cancel action"); + } + if (operationName == null || operationName.isBlank()) { + throw new IllegalArgumentException("a cancellable operation needs a name"); + } + coordinator.register( + new dev.caskeleton.grpc.deadline.GrpcCancellableOperation() { + @Override + public String name() { + return operationName; + } + + @Override + public void cancel(GrpcCancellationReason reason) { + cancelScope.run(); + } + }); + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/kotlin/GrpcKotlinCompatibilityGate.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/kotlin/GrpcKotlinCompatibilityGate.java new file mode 100644 index 00000000..46d3e635 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/kotlin/GrpcKotlinCompatibilityGate.java @@ -0,0 +1,69 @@ +package dev.caskeleton.grpc.advanced.kotlin; + +import java.util.ArrayList; +import java.util.List; + +/** + * Whether a Kotlin adapter may be advertised as supported. + * + *

Fails closed in this repository, and says so rather than reporting a pass it cannot justify. + * There is no Kotlin toolchain here (adaptation design D7), so the compile lane that would + * establish the last requirement has never run; a gate that reported success anyway would put an + * unverified claim in the support matrix. + * + *

The other four requirements are checkable and are checked, so turning the toolchain on later + * is a lane to add rather than a contract to write. + */ +public final class GrpcKotlinCompatibilityGate { + + private GrpcKotlinCompatibilityGate() {} + + /** + * Every reason a Kotlin adapter is not yet supportable. + * + * @param toolchainLaneRan whether a Kotlin compile lane actually ran against this profile + * @return an empty list only when the profile is complete and its lane has run + */ + public static List blockers(GrpcKotlinProfile profile, boolean toolchainLaneRan) { + if (profile == null) { + throw new IllegalArgumentException("a Kotlin profile is required"); + } + List blockers = new ArrayList<>(); + if (!profile.sharesSchemaSourceWithJava()) { + blockers.add( + "the Kotlin contract does not share one schema source with the Java contract; two " + + "sources diverge where only somebody reading both would notice"); + } + if (!profile.propagatesCoroutineCancellation()) { + blockers.add( + "coroutine cancellation does not reach the gRPC call; a cancelled scope would leave the " + + "call running"); + } + if (!profile.respectsStableFlowControl()) { + blockers.add( + "Flow backpressure bypasses the Stable buffer bounds; a slow collector would buffer " + + "without a limit rather than terminating the stream"); + } + if (!profile.preservesPlatformEvidenceTypes()) { + blockers.add( + "the adapter does not preserve the platform's evidence, status and deadline types; a " + + "Kotlin-idiomatic re-creation is a second model of the same facts"); + } + if (!toolchainLaneRan) { + blockers.add( + "no Kotlin toolchain lane has run against " + + profile.kotlinToolchainVersion() + + "; this repository has no Kotlin toolchain, so the compile evidence does not exist"); + } + return List.copyOf(blockers); + } + + /** + * Whether the Kotlin adapter is supportable in this repository as it stands. + * + *

False. The four contract requirements can be satisfied; the toolchain lane cannot. + */ + public static boolean supportableHere() { + return false; + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/kotlin/GrpcKotlinProfile.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/kotlin/GrpcKotlinProfile.java new file mode 100644 index 00000000..fb2b83fe --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/kotlin/GrpcKotlinProfile.java @@ -0,0 +1,35 @@ +package dev.caskeleton.grpc.advanced.kotlin; + +/** + * How a Kotlin adapter must behave, stated from the Java side. + * + *

Java-side because this repository has no Kotlin toolchain (adaptation design D7). What can be + * expressed without one is every rule that constrains the adapter: one schema source shared with + * the Java contract, coroutine cancellation propagated to the call, Flow backpressure inside the + * Stable buffer bounds, and the platform's evidence and status types preserved rather than + * re-created in Kotlin idiom. + * + *

Each is a component so the gate can check them individually. A single "compatible" flag would + * let a partially compliant adapter through, and the most likely partial failure — a Flow that + * buffers without a bound — is the one with the worst production behaviour. + */ +public record GrpcKotlinProfile( + boolean sharesSchemaSourceWithJava, + boolean propagatesCoroutineCancellation, + boolean respectsStableFlowControl, + boolean preservesPlatformEvidenceTypes, + String kotlinToolchainVersion) { + + /** Refuses a profile that claims support without naming a toolchain. */ + public GrpcKotlinProfile { + if (kotlinToolchainVersion == null || kotlinToolchainVersion.isBlank()) { + throw new IllegalArgumentException( + "a Kotlin profile names the toolchain it was verified against; 'Kotlin' is not a version"); + } + } + + /** A profile for a toolchain this repository has not verified. */ + public static GrpcKotlinProfile unverified(String kotlinToolchainVersion) { + return new GrpcKotlinProfile(false, false, false, false, kotlinToolchainVersion); + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/reactor/GrpcReactorCancellationBridge.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/reactor/GrpcReactorCancellationBridge.java new file mode 100644 index 00000000..52ce308c --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/reactor/GrpcReactorCancellationBridge.java @@ -0,0 +1,61 @@ +package dev.caskeleton.grpc.advanced.reactor; + +import dev.caskeleton.grpc.deadline.GrpcCancellationCoordinator; +import dev.caskeleton.grpc.deadline.GrpcCancellationReason; +import java.time.Instant; +import java.util.function.Supplier; +import reactor.core.Disposable; + +/** + * Turns a Reactor subscription's cancellation into the platform's. + * + *

Both directions, and both are needed. A client that disposes its {@code Mono} has stopped + * caring, and without this bridge the server keeps computing an answer nobody will read; a call the + * platform cancelled — deadline, drain, revoked credential — has to stop the reactive pipeline, or + * the work continues after the response has been closed. + */ +public final class GrpcReactorCancellationBridge { + + private GrpcReactorCancellationBridge() {} + + /** + * A callback for {@code doOnCancel} that cancels the platform call. + * + * @param now supplies the moment, so a test does not depend on the wall clock + */ + public static Runnable onReactorCancel( + GrpcCancellationCoordinator coordinator, Supplier now) { + if (coordinator == null || now == null) { + throw new IllegalArgumentException("a cancellation bridge needs a coordinator and a clock"); + } + return () -> coordinator.cancel(GrpcCancellationReason.CLIENT_CANCELLED, now.get()); + } + + /** + * Disposes the reactive pipeline when the platform cancels. + * + *

Registered as a cancellable operation, so it is reached by the same cancellation that stops + * the database query and the stream writer rather than by a second mechanism. + */ + public static void bindPlatformCancellation( + GrpcCancellationCoordinator coordinator, Disposable subscription, String operationName) { + if (coordinator == null || subscription == null) { + throw new IllegalArgumentException("binding needs a coordinator and a subscription"); + } + if (operationName == null || operationName.isBlank()) { + throw new IllegalArgumentException("a cancellable operation needs a name"); + } + coordinator.register( + new dev.caskeleton.grpc.deadline.GrpcCancellableOperation() { + @Override + public String name() { + return operationName; + } + + @Override + public void cancel(GrpcCancellationReason reason) { + subscription.dispose(); + } + }); + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/reactor/GrpcReactorContextBridge.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/reactor/GrpcReactorContextBridge.java new file mode 100644 index 00000000..8eccbdcf --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/reactor/GrpcReactorContextBridge.java @@ -0,0 +1,58 @@ +package dev.caskeleton.grpc.advanced.reactor; + +import dev.caskeleton.grpc.context.GrpcContextSnapshot; +import java.util.Optional; +import reactor.util.context.Context; +import reactor.util.context.ContextView; + +/** + * Carries the call context between gRPC's {@code Context} and Reactor's. + * + *

Explicitly, because neither propagates into the other. gRPC's context is a thread-local + * mechanism and Reactor's travels with the subscription, so an operator that hops threads leaves + * the gRPC context behind and a gRPC interceptor cannot see the Reactor one. Work downstream of + * that boundary then runs with no actor, no tenant and no deadline, which is the failure that + * produces a change attributed to nobody. + */ +public final class GrpcReactorContextBridge { + + private static final String CONTEXT_KEY = "dev.caskeleton.grpc.contextSnapshot"; + + private GrpcReactorContextBridge() {} + + /** Puts the snapshot into a Reactor context. */ + public static Context write(Context context, GrpcContextSnapshot snapshot) { + if (context == null || snapshot == null) { + throw new IllegalArgumentException("bridging needs a Reactor context and a snapshot"); + } + return context.put(CONTEXT_KEY, snapshot); + } + + /** Reads the snapshot out of a Reactor context, if it is there. */ + public static Optional read(ContextView context) { + if (context == null) { + throw new IllegalArgumentException("a Reactor context view is required"); + } + return context.hasKey(CONTEXT_KEY) ? Optional.of(context.get(CONTEXT_KEY)) : Optional.empty(); + } + + /** + * Reads the snapshot, failing when it is absent. + * + * @throws IllegalStateException because reactive work with no call context has no actor, no + * tenant and no deadline + */ + public static GrpcContextSnapshot require(ContextView context) { + return read(context) + .orElseThrow( + () -> + new IllegalStateException( + "no gRPC call context is in the Reactor context; reactive work that runs " + + "without one produces a change attributed to nobody")); + } + + /** The key the snapshot travels under, for a test or a diagnostic to look at. */ + public static String contextKey() { + return CONTEXT_KEY; + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/reactor/ReactiveGrpcClient.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/reactor/ReactiveGrpcClient.java new file mode 100644 index 00000000..fc0988d0 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/reactor/ReactiveGrpcClient.java @@ -0,0 +1,57 @@ +package dev.caskeleton.grpc.advanced.reactor; + +import dev.caskeleton.grpc.context.GrpcContextSnapshot; +import java.util.List; +import java.util.function.Supplier; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Exposes a unary call as a {@code Mono} and a server stream as a {@code Flux}. + * + *

Only here. The Stable contract types stay free of Reactor, so a deployment that does not use + * it does not carry it, and the reactive shape is a view over the platform rather than the + * platform's own vocabulary. + * + *

The {@code Flux} respects the Stable bounded flow control rather than replacing it. Reactor's + * backpressure and gRPC's are two mechanisms over one connection; letting a subscriber's {@code + * request(n)} drive the writer directly would bypass the bounded queue that decides what happens + * when a consumer falls behind. + */ +public final class ReactiveGrpcClient { + + private ReactiveGrpcClient() {} + + /** + * A unary call as a {@code Mono}, with the call context carried in the Reactor context. + * + * @param call the blocking invocation, run on {@code boundedElasticScheduler} rather than on the + * calling thread. A blocking call on an event loop stalls every other call sharing it, and + * the mistake is invisible until load arrives. + */ + public static Mono unary( + Supplier call, GrpcContextSnapshot snapshot, reactor.core.scheduler.Scheduler blocking) { + if (call == null || snapshot == null || blocking == null) { + throw new IllegalArgumentException( + "a reactive unary call needs an invocation, a context and a scheduler"); + } + return Mono.fromSupplier(call) + .subscribeOn(blocking) + .contextWrite(context -> GrpcReactorContextBridge.write(context, snapshot)); + } + + /** + * A server stream as a {@code Flux}. + * + * @param messages the already-bounded message source. Taking a list rather than a producer is + * deliberate: the bound belongs to the Stable stream writer, and a producer here would be a + * second place to get it wrong. + */ + public static Flux serverStream(List messages, GrpcContextSnapshot snapshot) { + if (messages == null || snapshot == null) { + throw new IllegalArgumentException("a reactive stream needs its messages and a context"); + } + return Flux.fromIterable(messages) + .contextWrite(context -> GrpcReactorContextBridge.write(context, snapshot)); + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/reactor/ReactiveGrpcServerAdapter.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/reactor/ReactiveGrpcServerAdapter.java new file mode 100644 index 00000000..ca54efe2 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/reactor/ReactiveGrpcServerAdapter.java @@ -0,0 +1,57 @@ +package dev.caskeleton.grpc.advanced.reactor; + +import dev.caskeleton.grpc.context.GrpcContextSnapshot; +import dev.caskeleton.grpc.context.GrpcRequestContext; +import java.util.function.BiFunction; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; + +/** + * Runs a reactive use case behind a gRPC service adapter. + * + *

The blocking scheduler is a required argument rather than a default. A reactive pipeline that + * touches JPA or a blocking SDK and does not say where that happens runs it on the event loop, and + * the symptom — every call on the connection slowing together — appears only under load and points + * at the wrong component. + * + * @param the application command type + * @param the application result type + */ +public final class ReactiveGrpcServerAdapter { + + private final BiFunction> useCase; + private final Scheduler blockingScheduler; + + /** Binds an adapter to its use case and the scheduler blocking work runs on. */ + public ReactiveGrpcServerAdapter( + BiFunction> useCase, Scheduler blockingScheduler) { + if (useCase == null || blockingScheduler == null) { + throw new IllegalArgumentException( + "a reactive adapter needs a use case and the scheduler its blocking work runs on"); + } + this.useCase = useCase; + this.blockingScheduler = blockingScheduler; + } + + /** + * Invokes the use case with the context in both the Reactor context and the argument. + * + *

Both, because they are read by different code: application operators read the Reactor + * context, and the use case signature reads the argument. Supplying only one leaves the other + * empty at a boundary nobody expects. + */ + public Mono invoke(C command, GrpcRequestContext context, GrpcContextSnapshot snapshot) { + if (command == null || context == null || snapshot == null) { + throw new IllegalArgumentException( + "an invocation needs a command, a request context and the snapshot to carry"); + } + return Mono.defer(() -> useCase.apply(command, context)) + .subscribeOn(blockingScheduler) + .contextWrite(reactorContext -> GrpcReactorContextBridge.write(reactorContext, snapshot)); + } + + /** The scheduler blocking work runs on. */ + public Scheduler blockingScheduler() { + return blockingScheduler; + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/servlet/GrpcServletCapabilityMatrix.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/servlet/GrpcServletCapabilityMatrix.java new file mode 100644 index 00000000..da912e80 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/servlet/GrpcServletCapabilityMatrix.java @@ -0,0 +1,50 @@ +package dev.caskeleton.grpc.advanced.servlet; + +import java.util.Set; + +/** + * What a Servlet container can and cannot do compared with Netty. + * + *

The gaps are not incidental. A Servlet container owns the socket, so everything below the + * request abstraction belongs to it: keepalive, connection age, and the flow-control window are the + * container's settings, not gRPC's. Naming them is what stops a deployment from configuring a + * keepalive that is silently ignored and concluding the client is at fault. + */ +public enum GrpcServletCapabilityMatrix { + /** HTTP/2 request and response. Available. */ + HTTP2(true), + /** TLS. Available, terminated by the container. */ + TLS(true), + /** Inbound message and metadata limits. Available. */ + MESSAGE_LIMITS(true), + /** The standard health service. Available. */ + HEALTH(true), + /** Server reflection. Available. */ + REFLECTION(true), + /** Graceful shutdown. Available through the container's lifecycle. */ + GRACEFUL_SHUTDOWN(true), + /** gRPC-level keepalive tuning. The container owns the connection. */ + KEEPALIVE_TUNING(false), + /** Maximum connection age. The container owns the connection. */ + MAX_CONNECTION_AGE(false), + /** HTTP/2 flow-control window tuning. The container owns the transport. */ + FLOW_CONTROL_WINDOW_TUNING(false); + + private final boolean available; + + GrpcServletCapabilityMatrix(boolean available) { + this.available = available; + } + + /** Whether the Servlet transport provides this. */ + public boolean available() { + return available; + } + + /** Everything the Servlet transport cannot do. */ + public static Set unavailable() { + return java.util.Arrays.stream(values()) + .filter(capability -> !capability.available()) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/servlet/GrpcServletCompatibilityProfile.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/servlet/GrpcServletCompatibilityProfile.java new file mode 100644 index 00000000..8363b0d6 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/servlet/GrpcServletCompatibilityProfile.java @@ -0,0 +1,51 @@ +package dev.caskeleton.grpc.advanced.servlet; + +import dev.caskeleton.grpc.server.GrpcServerTransport; + +/** + * A deployment that serves gRPC from its Servlet container, on the web server's own port. + * + *

Attractive because it is one port, one TLS configuration and one lifecycle. The cost is that + * the container owns the transport, so the Netty settings a Stable profile carries have no effect — + * and this profile refuses to pretend otherwise. + * + *

Its evidence never counts as Netty certification. A suite that passes here has established + * that the container serves gRPC, not that the platform's Netty profile is correct. + */ +public record GrpcServletCompatibilityProfile( + String contextPath, boolean containerOwnsTls, boolean asyncSupported) { + + /** Refuses a configuration the container cannot honour. */ + public GrpcServletCompatibilityProfile { + if (contextPath == null || !contextPath.startsWith("/")) { + throw new IllegalArgumentException( + "a servlet profile needs a context path starting with '/'"); + } + if (!asyncSupported) { + throw new IllegalArgumentException( + "gRPC over Servlet requires async support; without it every streaming call blocks a " + + "container thread for its lifetime"); + } + if (!containerOwnsTls) { + throw new IllegalArgumentException( + "the container owns the socket, so it owns TLS; a profile that claims otherwise " + + "configures a setting nothing reads"); + } + } + + /** The transport this profile runs on. */ + public GrpcServerTransport transport() { + return GrpcServerTransport.SERVLET; + } + + /** + * Whether a run under this profile certifies the platform's Netty transport. + * + *

Always false. The container's HTTP/2 is real, but it is the container's: its keepalive, its + * connection age and its flow-control window. Stable certification is a statement about the + * platform's own Netty profile, and a Servlet run establishes nothing about it. + */ + public boolean certifiesNettyTransport() { + return false; + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/servlet/GrpcServletStartupValidator.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/servlet/GrpcServletStartupValidator.java new file mode 100644 index 00000000..8df49a98 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/servlet/GrpcServletStartupValidator.java @@ -0,0 +1,51 @@ +package dev.caskeleton.grpc.advanced.servlet; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * Refuses a Servlet deployment that asks for something the container will not do. + * + *

Refuses rather than warns, because the setting would otherwise be accepted and ignored. A + * keepalive configured on a Servlet deployment does nothing, the connections behave as the + * container decides, and the investigation starts from the assumption that the setting is in force. + */ +public final class GrpcServletStartupValidator { + + private GrpcServletStartupValidator() {} + + /** + * Every requested capability the Servlet transport cannot provide. + * + * @param requestedCapabilities the transport settings the deployment configured + * @return an empty list when everything requested is available + */ + public static List violations( + GrpcServletCompatibilityProfile profile, + Set requestedCapabilities) { + if (profile == null || requestedCapabilities == null) { + throw new IllegalArgumentException("validation needs the profile and the requested set"); + } + List violations = new ArrayList<>(); + requestedCapabilities.stream() + .filter(capability -> !capability.available()) + .sorted() + .forEach( + capability -> + violations.add( + capability + + " is not available on the Servlet transport; the container owns the " + + "connection, so this setting would be accepted and ignored")); + return List.copyOf(violations); + } + + /** + * Whether a Servlet run may stand in for Netty certification. + * + *

Always false. + */ + public static boolean substitutesForNettyCertification() { + return false; + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/web/GrpcWebCompatibilityGate.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/web/GrpcWebCompatibilityGate.java new file mode 100644 index 00000000..2ad2677e --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/web/GrpcWebCompatibilityGate.java @@ -0,0 +1,56 @@ +package dev.caskeleton.grpc.advanced.web; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Checks that everything exposed to browsers can actually be served to them, on the same schema as + * the native clients. + * + *

The same schema is the point. Two schemas — one for browsers, one for services — is how a + * field ends up meaning something different depending on which client asked, and the divergence is + * only visible to whoever reads both files. + */ +public final class GrpcWebCompatibilityGate { + + private GrpcWebCompatibilityGate() {} + + /** + * Every method this profile could not serve. + * + * @param exposedMethods the methods a browser is meant to be able to call + * @return an empty list when every exposed method is servable + */ + public static List violations(Map exposedMethods) { + if (exposedMethods == null) { + throw new IllegalArgumentException("an exposed method map is required"); + } + List violations = new ArrayList<>(); + exposedMethods.entrySet().stream() + .sorted(java.util.Comparator.comparing(entry -> entry.getKey().canonical())) + .forEach( + entry -> { + if (!GrpcWebRpcSupport.supported(entry.getValue())) { + violations.add( + "method '" + + entry.getKey().canonical() + + "' is " + + entry.getValue() + + ", which gRPC-Web cannot carry"); + } + }); + return List.copyOf(violations); + } + + /** + * Whether the browser and native suites must run against one schema. + * + *

Always true. Stated as a method so the property is asserted rather than described. + */ + public static boolean sharesOneSchemaWithNativeClients() { + return true; + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/web/GrpcWebProfile.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/web/GrpcWebProfile.java new file mode 100644 index 00000000..194bd33a --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/web/GrpcWebProfile.java @@ -0,0 +1,62 @@ +package dev.caskeleton.grpc.advanced.web; + +import java.util.Set; + +/** + * How a browser reaches the platform, and under what browser-specific rules. + * + *

Cookie credentials and bearer credentials are separated because their attack surfaces differ. + * A cookie is attached by the browser to every request to the origin, which makes CSRF a real + * concern and a CSRF defence mandatory; a bearer token the application attaches explicitly is not + * sent automatically, and requiring a CSRF token there is ceremony. A profile that treats them the + * same either leaves the first exposed or burdens the second. + */ +public record GrpcWebProfile( + CredentialStyle credentialStyle, + Set allowedOrigins, + boolean csrfProtection, + boolean tlsTerminatedAtProxy) { + + /** How the browser presents its credential. */ + public enum CredentialStyle { + /** No credential. */ + NONE, + /** A cookie the browser attaches automatically. Needs a CSRF defence. */ + COOKIE, + /** A bearer token the application attaches explicitly. */ + BEARER + } + + /** Refuses a profile a browser could be tricked into using. */ + public GrpcWebProfile { + if (credentialStyle == null) { + throw new IllegalArgumentException("a gRPC-Web profile names its credential style"); + } + if (allowedOrigins == null) { + throw new IllegalArgumentException("a gRPC-Web profile states its origin allowlist"); + } + allowedOrigins = Set.copyOf(allowedOrigins); + if (allowedOrigins.contains("*")) { + throw new IllegalArgumentException( + "a wildcard origin lets any site call this API with the browser's ambient credentials"); + } + if (credentialStyle != CredentialStyle.NONE && allowedOrigins.isEmpty()) { + throw new IllegalArgumentException( + "a credentialed gRPC-Web profile needs an origin allowlist"); + } + if (credentialStyle == CredentialStyle.COOKIE && !csrfProtection) { + throw new IllegalArgumentException( + "cookie credentials are attached by the browser to every request to this origin, so a " + + "CSRF defence is not optional"); + } + if (!tlsTerminatedAtProxy && credentialStyle != CredentialStyle.NONE) { + throw new IllegalArgumentException( + "a credentialed browser call needs TLS terminated at the proxy"); + } + } + + /** A bearer-token profile for a single origin. */ + public static GrpcWebProfile bearer(String origin) { + return new GrpcWebProfile(CredentialStyle.BEARER, Set.of(origin), false, true); + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/web/GrpcWebProxyContract.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/web/GrpcWebProxyContract.java new file mode 100644 index 00000000..ba27ad13 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/web/GrpcWebProxyContract.java @@ -0,0 +1,64 @@ +package dev.caskeleton.grpc.advanced.web; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * What a gRPC-Web proxy must be configured to do. + * + *

The exposed-trailer rule is the one that costs the most time when it is missing. A gRPC status + * arrives as a trailer, a browser cannot read a trailer the proxy did not expose, and the symptom + * is a call that appears to succeed at the network level and produces no status at all — which + * looks like an application bug and is a proxy configuration. + */ +public final class GrpcWebProxyContract { + + /** The trailers a browser client has to be able to read. */ + private static final Set REQUIRED_EXPOSED_HEADERS = Set.of("grpc-status", "grpc-message"); + + private GrpcWebProxyContract() {} + + /** The headers a proxy must expose. */ + public static Set requiredExposedHeaders() { + return REQUIRED_EXPOSED_HEADERS; + } + + /** + * Every problem with a proxy configuration. + * + * @param exposedHeaders the CORS {@code expose_headers} the proxy is configured with + * @param allowedOrigins the CORS origin allowlist the proxy is configured with + * @return an empty list when the proxy would work for a browser client + */ + public static List violations( + GrpcWebProfile profile, Set exposedHeaders, Set allowedOrigins) { + if (profile == null || exposedHeaders == null || allowedOrigins == null) { + throw new IllegalArgumentException("a proxy check needs the profile and both CORS sets"); + } + List violations = new ArrayList<>(); + + REQUIRED_EXPOSED_HEADERS.stream() + .sorted() + .filter(header -> !exposedHeaders.contains(header)) + .forEach( + header -> + violations.add( + "the proxy does not expose '" + + header + + "'; a browser cannot read a trailer it was not given, so the call " + + "produces no status at all")); + + if (allowedOrigins.contains("*")) { + violations.add("the proxy allows any origin, which defeats the profile's allowlist"); + } + profile.allowedOrigins().stream() + .sorted() + .filter(origin -> !allowedOrigins.contains(origin)) + .forEach( + origin -> + violations.add( + "origin '" + origin + "' is in the profile but not in the proxy's allowlist")); + return List.copyOf(violations); + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/web/GrpcWebRpcSupport.java b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/web/GrpcWebRpcSupport.java new file mode 100644 index 00000000..4005352e --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/java/dev/caskeleton/grpc/advanced/web/GrpcWebRpcSupport.java @@ -0,0 +1,38 @@ +package dev.caskeleton.grpc.advanced.web; + +import dev.caskeleton.grpc.core.RpcType; + +/** + * Which RPC shapes gRPC-Web can actually carry. + * + *

Two, and the limit is the protocol's rather than this platform's: gRPC-Web has no way for a + * browser to send a stream of messages, so client and bidirectional streaming are not slow or + * partial there — they do not exist. Declaring support for them produces a schema a browser client + * cannot use and a discovery that happens in the browser. + */ +public final class GrpcWebRpcSupport { + + private GrpcWebRpcSupport() {} + + /** Whether {@code rpcType} can travel over gRPC-Web. */ + public static boolean supported(RpcType rpcType) { + if (rpcType == null) { + throw new IllegalArgumentException("an RPC type is required"); + } + return rpcType == RpcType.UNARY || rpcType == RpcType.SERVER_STREAMING; + } + + /** + * Fails when a method shape cannot be served over gRPC-Web. + * + * @throws IllegalArgumentException naming what the browser cannot do + */ + public static void require(RpcType rpcType) { + if (!supported(rpcType)) { + throw new IllegalArgumentException( + rpcType + + " cannot travel over gRPC-Web: a browser has no way to send a stream of messages, " + + "so declaring support for it produces a schema no browser client can use"); + } + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/main/resources/envoy/envoy.yaml b/src/grpc-advanced/grpc-advanced-compat/src/main/resources/envoy/envoy.yaml new file mode 100644 index 00000000..0c5ec860 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/main/resources/envoy/envoy.yaml @@ -0,0 +1,70 @@ +# The gRPC-Web proxy contract, as a reference Envoy configuration. +# +# Shipped as a resource rather than as documentation prose because GrpcWebProxyContract asserts +# against it: the CORS allowlist, the exposed trailer headers and the TLS termination are the three +# things a browser client silently fails without, and a contract nobody checks is a contract that +# drifts from whatever is actually deployed. +# +# The exposed headers matter most and are the least obvious. gRPC statuses arrive as trailers, and a +# browser cannot read a trailer the proxy did not expose; the symptom is a call that appears to hang +# and then fails with no status at all. +static_resources: + listeners: + - name: grpc_web_listener + address: + socket_address: { address: 0.0.0.0, port_value: 8443 } + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: grpc_web + codec_type: AUTO + route_config: + name: grpc_web_route + virtual_hosts: + - name: grpc_web_host + domains: ["*"] + routes: + - match: { prefix: "/" } + route: + cluster: grpc_backend + timeout: 30s + cors: + allow_origin_string_match: + - exact: "https://app.example.com" + allow_methods: "POST,OPTIONS" + allow_headers: "content-type,x-grpc-web,x-correlation-id,authorization" + expose_headers: "grpc-status,grpc-message,error-code,error-category" + max_age: "1728000" + http_filters: + - name: envoy.filters.http.grpc_web + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb + - name: envoy.filters.http.cors + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext + clusters: + - name: grpc_backend + connect_timeout: 1s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + typed_extension_protocol_options: + envoy.extensions.upstreams.http.v3.HttpProtocolOptions: + "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions + explicit_http_config: + http2_protocol_options: {} + load_assignment: + cluster_name: grpc_backend + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: { address: documents, port_value: 9090 } diff --git a/src/grpc-advanced/grpc-advanced-compat/src/test/java/dev/caskeleton/grpc/advanced/compat/GrpcIntegrationBridgePolicyTest.java b/src/grpc-advanced/grpc-advanced-compat/src/test/java/dev/caskeleton/grpc/advanced/compat/GrpcIntegrationBridgePolicyTest.java new file mode 100644 index 00000000..768c37bb --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/test/java/dev/caskeleton/grpc/advanced/compat/GrpcIntegrationBridgePolicyTest.java @@ -0,0 +1,112 @@ +package dev.caskeleton.grpc.advanced.compat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.advanced.integration.GrpcIntegrationBridgePolicy; +import dev.caskeleton.grpc.advanced.integration.GrpcIntegrationInboundGateway; +import dev.caskeleton.grpc.advanced.integration.GrpcIntegrationOutboundGateway; +import dev.caskeleton.grpc.context.GrpcClientIdentity; +import dev.caskeleton.grpc.context.GrpcMetadataBudget; +import dev.caskeleton.grpc.context.GrpcMetadataKey; +import dev.caskeleton.grpc.context.GrpcRequestContext; +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.deadline.GrpcCancellationToken; +import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; +import java.time.Duration; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +class GrpcIntegrationBridgePolicyTest { + + private static final GrpcMetadataKey CORRELATION = GrpcMetadataKey.ascii("x-correlation-id"); + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + + private static GrpcRequestContext requestContext() { + return GrpcRequestContext.create( + GET, + RpcType.UNARY, + GrpcClientIdentity.fromVerifiedAuthentication("actor-1", "tenant-1", "jwt-issuer"), + GrpcDeadlineBudget.forEntryPoint( + Duration.ofSeconds(1), GrpcDeadlineProfile.of(Duration.ofSeconds(2))), + new GrpcCancellationToken(), + Map.of(CORRELATION, "corr-1"), + Set.of(CORRELATION), + GrpcMetadataBudget.standard(), + null); + } + + private static GrpcIntegrationBridgePolicy policy() { + return new GrpcIntegrationBridgePolicy(Set.of(CORRELATION), Set.of(String.class.getName())); + } + + @Test + @DisplayName("the bridge copies only allowlisted headers onto metadata") + void theBridgeCopiesOnlyAllowlistedHeaders() { + GrpcIntegrationOutboundGateway outbound = + new GrpcIntegrationOutboundGateway<>(policy()); + Message message = + MessageBuilder.withPayload("payload") + .setHeader(CORRELATION.name(), "corr-1") + .setHeader("errorChannel", "internal-errors") + .setHeader("replyChannel", "internal-replies") + .build(); + + assertThat(outbound.metadataFrom(message)).containsOnlyKeys(CORRELATION); + assertThat(outbound.payloadFrom(message)).isEqualTo("payload"); + assertThat(outbound.policy()).isNotNull(); + } + + @Test + @DisplayName("an unregistered payload type is refused rather than converted by reflection") + void anUnregisteredPayloadIsRefused() { + GrpcIntegrationBridgePolicy otherType = + new GrpcIntegrationBridgePolicy(Set.of(CORRELATION), Set.of("some.other.Type")); + GrpcIntegrationInboundGateway inbound = new GrpcIntegrationInboundGateway<>(otherType); + + assertThatThrownBy(() -> inbound.toMessage("payload", requestContext())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("converting by reflection"); + Message message = MessageBuilder.withPayload("payload").build(); + GrpcIntegrationOutboundGateway outbound = + new GrpcIntegrationOutboundGateway<>(otherType); + assertThatThrownBy(() -> outbound.payloadFrom(message)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a bridge with no registered converter is refused at construction") + void aBridgeWithNoConverterIsRefused() { + assertThatThrownBy(() -> new GrpcIntegrationBridgePolicy(Set.of(), Set.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("leaving it to reflection"); + } + + @Test + @DisplayName("the request context travels as one immutable header, not as scattered fields") + void theContextTravelsAsOneHeader() { + GrpcIntegrationInboundGateway inbound = new GrpcIntegrationInboundGateway<>(policy()); + + Message message = inbound.toMessage("payload", requestContext()); + + assertThat(message.getHeaders()) + .containsKey(GrpcIntegrationInboundGateway.CONTEXT_HEADER) + .containsKey(CORRELATION.name()) + .doesNotContainKey("tenantId") + .doesNotContainKey("actorId"); + } + + @Test + @DisplayName("the bridge adds no broker semantics and replaces no generated API") + void theBridgeClaimsNothingItCannotDo() { + assertThat(policy().providesBrokerSemantics()).isFalse(); + assertThat(policy().replacesGeneratedApis()).isFalse(); + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/test/java/dev/caskeleton/grpc/advanced/compat/GrpcKotlinCompatibilityGateTest.java b/src/grpc-advanced/grpc-advanced-compat/src/test/java/dev/caskeleton/grpc/advanced/compat/GrpcKotlinCompatibilityGateTest.java new file mode 100644 index 00000000..c70f5b42 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/test/java/dev/caskeleton/grpc/advanced/compat/GrpcKotlinCompatibilityGateTest.java @@ -0,0 +1,80 @@ +package dev.caskeleton.grpc.advanced.compat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.advanced.kotlin.GrpcCoroutineContextBridge; +import dev.caskeleton.grpc.advanced.kotlin.GrpcKotlinCompatibilityGate; +import dev.caskeleton.grpc.advanced.kotlin.GrpcKotlinProfile; +import dev.caskeleton.grpc.deadline.GrpcCancellationCoordinator; +import dev.caskeleton.grpc.deadline.GrpcCancellationReason; +import dev.caskeleton.grpc.deadline.GrpcCancellationToken; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcKotlinCompatibilityGateTest { + + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + + @Test + @DisplayName("the Kotlin adapter fails closed here, and says which lane is missing") + void theGateFailsClosed() { + GrpcKotlinProfile complete = new GrpcKotlinProfile(true, true, true, true, "2.1.0"); + + assertThat(GrpcKotlinCompatibilityGate.blockers(complete, false)) + .singleElement() + .satisfies(blocker -> assertThat(blocker).contains("no Kotlin toolchain")); + assertThat(GrpcKotlinCompatibilityGate.blockers(complete, true)).isEmpty(); + assertThat(GrpcKotlinCompatibilityGate.supportableHere()).isFalse(); + } + + @Test + @DisplayName("each Kotlin contract requirement is checked on its own") + void eachRequirementIsCheckedSeparately() { + assertThat(GrpcKotlinCompatibilityGate.blockers(GrpcKotlinProfile.unverified("2.1.0"), true)) + .hasSize(4) + .anySatisfy(blocker -> assertThat(blocker).contains("one schema source")) + .anySatisfy(blocker -> assertThat(blocker).contains("cancelled scope")) + .anySatisfy(blocker -> assertThat(blocker).contains("buffer without a limit")) + .anySatisfy(blocker -> assertThat(blocker).contains("second model of the same facts")); + } + + @Test + @DisplayName("a profile that claims support without naming a toolchain is refused") + void aProfileNamesItsToolchain() { + assertThatThrownBy(() -> GrpcKotlinProfile.unverified(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not a version"); + } + + @Test + @DisplayName("coroutine cancellation reaches the platform, and the platform reaches the scope") + void cancellationCrossesBothWays() { + GrpcCancellationCoordinator inbound = + new GrpcCancellationCoordinator(new GrpcCancellationToken()); + GrpcCoroutineContextBridge.onCoroutineCancelled(inbound, () -> NOW).run(); + assertThat(inbound.cancelled()).isTrue(); + + GrpcCancellationCoordinator outbound = + new GrpcCancellationCoordinator(new GrpcCancellationToken()); + AtomicBoolean scopeCancelled = new AtomicBoolean(); + GrpcCoroutineContextBridge.cancelCoroutineScope( + outbound, () -> scopeCancelled.set(true), "document-flow"); + outbound.cancel(GrpcCancellationReason.SERVER_DRAIN, NOW); + + assertThat(scopeCancelled).isTrue(); + } + + @Test + @DisplayName("binding a coroutine scope without a name is refused") + void bindingNeedsAnOperationName() { + GrpcCancellationCoordinator coordinator = + new GrpcCancellationCoordinator(new GrpcCancellationToken()); + + assertThatThrownBy( + () -> GrpcCoroutineContextBridge.cancelCoroutineScope(coordinator, () -> {}, " ")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/test/java/dev/caskeleton/grpc/advanced/compat/GrpcReactorContextBridgeTest.java b/src/grpc-advanced/grpc-advanced-compat/src/test/java/dev/caskeleton/grpc/advanced/compat/GrpcReactorContextBridgeTest.java new file mode 100644 index 00000000..de58a760 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/test/java/dev/caskeleton/grpc/advanced/compat/GrpcReactorContextBridgeTest.java @@ -0,0 +1,108 @@ +package dev.caskeleton.grpc.advanced.compat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.advanced.reactor.GrpcReactorCancellationBridge; +import dev.caskeleton.grpc.advanced.reactor.GrpcReactorContextBridge; +import dev.caskeleton.grpc.context.GrpcClientIdentity; +import dev.caskeleton.grpc.context.GrpcContextSnapshot; +import dev.caskeleton.grpc.context.GrpcMetadataBudget; +import dev.caskeleton.grpc.context.GrpcRequestContext; +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.deadline.GrpcCancellationCoordinator; +import dev.caskeleton.grpc.deadline.GrpcCancellationReason; +import dev.caskeleton.grpc.deadline.GrpcCancellationToken; +import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import reactor.util.context.Context; + +class GrpcReactorContextBridgeTest { + + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + + private static GrpcContextSnapshot snapshot() { + GrpcRequestContext request = + GrpcRequestContext.create( + GET, + RpcType.UNARY, + GrpcClientIdentity.fromVerifiedAuthentication("actor-1", "tenant-1", "jwt-issuer"), + GrpcDeadlineBudget.forEntryPoint( + Duration.ofSeconds(1), GrpcDeadlineProfile.of(Duration.ofSeconds(2))), + new GrpcCancellationToken(), + Map.of(), + Set.of(), + GrpcMetadataBudget.standard(), + null); + return GrpcContextSnapshot.of(request, null); + } + + @Test + @DisplayName("the call context crosses into and out of a Reactor context") + void theContextCrossesIntoReactor() { + GrpcContextSnapshot snapshot = snapshot(); + + Context context = GrpcReactorContextBridge.write(Context.empty(), snapshot); + + assertThat(GrpcReactorContextBridge.read(context)).contains(snapshot); + assertThat(GrpcReactorContextBridge.require(context).identity().actorId()).isEqualTo("actor-1"); + assertThat(GrpcReactorContextBridge.contextKey()).isNotBlank(); + } + + @Test + @DisplayName("reactive work with no call context fails closed") + void contextlessReactiveWorkFailsClosed() { + assertThat(GrpcReactorContextBridge.read(Context.empty())).isEmpty(); + assertThatThrownBy(() -> GrpcReactorContextBridge.require(Context.empty())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("attributed to nobody"); + } + + @Test + @DisplayName("a Reactor cancellation reaches the platform coordinator") + void reactorCancellationReachesThePlatform() { + GrpcCancellationCoordinator coordinator = + new GrpcCancellationCoordinator(new GrpcCancellationToken()); + + GrpcReactorCancellationBridge.onReactorCancel(coordinator, () -> NOW).run(); + + assertThat(coordinator.cancelled()).isTrue(); + assertThat(coordinator.reason()).contains(GrpcCancellationReason.CLIENT_CANCELLED); + } + + @Test + @DisplayName("a platform cancellation disposes the reactive subscription") + void platformCancellationDisposesTheSubscription() { + GrpcCancellationCoordinator coordinator = + new GrpcCancellationCoordinator(new GrpcCancellationToken()); + AtomicBoolean disposed = new AtomicBoolean(); + + GrpcReactorCancellationBridge.bindPlatformCancellation( + coordinator, () -> disposed.set(true), "document-query"); + coordinator.cancel(GrpcCancellationReason.DEADLINE_EXCEEDED, NOW); + + assertThat(disposed).isTrue(); + } + + @Test + @DisplayName("binding a subscription without a name is refused") + void bindingNeedsAnOperationName() { + GrpcCancellationCoordinator coordinator = + new GrpcCancellationCoordinator(new GrpcCancellationToken()); + + assertThatThrownBy( + () -> + GrpcReactorCancellationBridge.bindPlatformCancellation(coordinator, () -> {}, " ")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/test/java/dev/caskeleton/grpc/advanced/compat/GrpcServletStartupValidatorTest.java b/src/grpc-advanced/grpc-advanced-compat/src/test/java/dev/caskeleton/grpc/advanced/compat/GrpcServletStartupValidatorTest.java new file mode 100644 index 00000000..127e04b9 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/test/java/dev/caskeleton/grpc/advanced/compat/GrpcServletStartupValidatorTest.java @@ -0,0 +1,79 @@ +package dev.caskeleton.grpc.advanced.compat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.advanced.servlet.GrpcServletCapabilityMatrix; +import dev.caskeleton.grpc.advanced.servlet.GrpcServletCompatibilityProfile; +import dev.caskeleton.grpc.advanced.servlet.GrpcServletStartupValidator; +import dev.caskeleton.grpc.server.GrpcServerTransport; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcServletStartupValidatorTest { + + private static final GrpcServletCompatibilityProfile PROFILE = + new GrpcServletCompatibilityProfile("/grpc", true, true); + + @Test + @DisplayName("the Servlet transport names what it cannot do") + void theMatrixNamesItsGaps() { + assertThat(GrpcServletCapabilityMatrix.unavailable()) + .containsExactlyInAnyOrder( + GrpcServletCapabilityMatrix.KEEPALIVE_TUNING, + GrpcServletCapabilityMatrix.MAX_CONNECTION_AGE, + GrpcServletCapabilityMatrix.FLOW_CONTROL_WINDOW_TUNING); + assertThat(GrpcServletCapabilityMatrix.HTTP2.available()).isTrue(); + assertThat(GrpcServletCapabilityMatrix.GRACEFUL_SHUTDOWN.available()).isTrue(); + } + + @Test + @DisplayName("a deployment asking for a Netty-only setting is refused, not warned") + void aNettyOnlySettingIsRefused() { + assertThat( + GrpcServletStartupValidator.violations( + PROFILE, Set.of(GrpcServletCapabilityMatrix.KEEPALIVE_TUNING))) + .singleElement() + .satisfies(violation -> assertThat(violation).contains("accepted and ignored")); + assertThat( + GrpcServletStartupValidator.violations( + PROFILE, + Set.of( + GrpcServletCapabilityMatrix.KEEPALIVE_TUNING, + GrpcServletCapabilityMatrix.MAX_CONNECTION_AGE))) + .hasSize(2); + } + + @Test + @DisplayName("an available capability passes") + void anAvailableCapabilityPasses() { + assertThat( + GrpcServletStartupValidator.violations( + PROFILE, + Set.of(GrpcServletCapabilityMatrix.HTTP2, GrpcServletCapabilityMatrix.TLS))) + .isEmpty(); + } + + @Test + @DisplayName("a Servlet run never substitutes for Netty certification") + void servletIsNotNettyCertification() { + assertThat(GrpcServletStartupValidator.substitutesForNettyCertification()).isFalse(); + assertThat(PROFILE.certifiesNettyTransport()).isFalse(); + assertThat(PROFILE.transport()).isEqualTo(GrpcServerTransport.SERVLET); + assertThat(GrpcServerTransport.SERVLET.certifiesNetworkBehaviour()).isFalse(); + } + + @Test + @DisplayName("a profile without async support or container-owned TLS is refused") + void anIncoherentProfileIsRefused() { + assertThatThrownBy(() -> new GrpcServletCompatibilityProfile("/grpc", true, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("blocks a container thread"); + assertThatThrownBy(() -> new GrpcServletCompatibilityProfile("/grpc", false, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("a setting nothing reads"); + assertThatThrownBy(() -> new GrpcServletCompatibilityProfile("grpc", true, true)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/grpc-advanced/grpc-advanced-compat/src/test/java/dev/caskeleton/grpc/advanced/compat/GrpcWebCompatibilityGateTest.java b/src/grpc-advanced/grpc-advanced-compat/src/test/java/dev/caskeleton/grpc/advanced/compat/GrpcWebCompatibilityGateTest.java new file mode 100644 index 00000000..ff67a6d9 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-compat/src/test/java/dev/caskeleton/grpc/advanced/compat/GrpcWebCompatibilityGateTest.java @@ -0,0 +1,144 @@ +package dev.caskeleton.grpc.advanced.compat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.advanced.web.GrpcWebCompatibilityGate; +import dev.caskeleton.grpc.advanced.web.GrpcWebProfile; +import dev.caskeleton.grpc.advanced.web.GrpcWebProxyContract; +import dev.caskeleton.grpc.advanced.web.GrpcWebRpcSupport; +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcWebCompatibilityGateTest { + + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + private static final GrpcMethodName UPLOAD = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/UploadDocument"); + + @Test + @DisplayName("gRPC-Web carries unary and server streaming, and nothing else") + void grpcWebCarriesTwoShapes() { + assertThat(GrpcWebRpcSupport.supported(RpcType.UNARY)).isTrue(); + assertThat(GrpcWebRpcSupport.supported(RpcType.SERVER_STREAMING)).isTrue(); + assertThat(GrpcWebRpcSupport.supported(RpcType.CLIENT_STREAMING)).isFalse(); + assertThat(GrpcWebRpcSupport.supported(RpcType.BIDI_STREAMING)).isFalse(); + assertThatThrownBy(() -> GrpcWebRpcSupport.require(RpcType.BIDI_STREAMING)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("no way to send a stream of messages"); + } + + @Test + @DisplayName("a client-streaming method exposed to browsers is reported") + void anUnservableMethodIsReported() { + assertThat( + GrpcWebCompatibilityGate.violations( + Map.of(GET, RpcType.UNARY, UPLOAD, RpcType.CLIENT_STREAMING))) + .singleElement() + .satisfies(violation -> assertThat(violation).contains("UploadDocument")); + assertThat(GrpcWebCompatibilityGate.violations(Map.of(GET, RpcType.UNARY))).isEmpty(); + assertThat(GrpcWebCompatibilityGate.sharesOneSchemaWithNativeClients()).isTrue(); + } + + @Test + @DisplayName("a wildcard origin is refused, and cookie credentials require CSRF protection") + void browserCredentialRulesDifferByStyle() { + assertThatThrownBy( + () -> + new GrpcWebProfile(GrpcWebProfile.CredentialStyle.BEARER, Set.of("*"), false, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ambient credentials"); + assertThatThrownBy( + () -> + new GrpcWebProfile( + GrpcWebProfile.CredentialStyle.COOKIE, + Set.of("https://app.example.com"), + false, + true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CSRF defence is not optional"); + assertThat(GrpcWebProfile.bearer("https://app.example.com").csrfProtection()).isFalse(); + } + + @Test + @DisplayName("a credentialed profile needs an origin allowlist and TLS at the proxy") + void aCredentialedProfileNeedsBoth() { + assertThatThrownBy( + () -> new GrpcWebProfile(GrpcWebProfile.CredentialStyle.BEARER, Set.of(), false, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("origin allowlist"); + assertThatThrownBy( + () -> + new GrpcWebProfile( + GrpcWebProfile.CredentialStyle.BEARER, + Set.of("https://app.example.com"), + false, + false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("TLS terminated at the proxy"); + } + + @Test + @DisplayName("a proxy that hides the status trailers is reported") + void aProxyMustExposeTheStatusTrailers() { + GrpcWebProfile profile = GrpcWebProfile.bearer("https://app.example.com"); + + assertThat( + GrpcWebProxyContract.violations( + profile, Set.of("grpc-status"), Set.of("https://app.example.com"))) + .anySatisfy(violation -> assertThat(violation).contains("grpc-message")); + assertThat( + GrpcWebProxyContract.violations( + profile, + GrpcWebProxyContract.requiredExposedHeaders(), + Set.of("https://app.example.com"))) + .isEmpty(); + } + + @Test + @DisplayName("a proxy allowing any origin, or missing the profile's, is reported") + void proxyOriginMismatchesAreReported() { + GrpcWebProfile profile = GrpcWebProfile.bearer("https://app.example.com"); + + assertThat( + GrpcWebProxyContract.violations( + profile, GrpcWebProxyContract.requiredExposedHeaders(), Set.of("*"))) + .anySatisfy(violation -> assertThat(violation).contains("defeats the profile's allowlist")); + assertThat( + GrpcWebProxyContract.violations( + profile, GrpcWebProxyContract.requiredExposedHeaders(), Set.of())) + .anySatisfy(violation -> assertThat(violation).contains("not in the proxy's allowlist")); + } + + @Test + @DisplayName("the reference Envoy configuration exposes the trailers and pins the origin") + void theReferenceProxyConfigurationIsCorrect() { + String envoy = resource("envoy/envoy.yaml"); + + assertThat(envoy) + .contains("expose_headers: \"grpc-status,grpc-message") + .contains("exact: \"https://app.example.com\"") + .contains("envoy.filters.http.grpc_web"); + } + + private static String resource(String path) { + try (InputStream stream = + GrpcWebCompatibilityGateTest.class.getClassLoader().getResourceAsStream(path)) { + if (stream == null) { + throw new IllegalStateException("missing resource " + path); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/grpc-advanced/grpc-advanced-diagnostics/build.gradle b/src/grpc-advanced/grpc-advanced-diagnostics/build.gradle new file mode 100644 index 00000000..decae0c5 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-diagnostics/build.gradle @@ -0,0 +1,10 @@ +apply plugin: 'java-library' + +// Channelz/CSDS diagnostics for administrators, with the redactor that keeps socket authority, +// credentials, certificate material, metadata and payload out of a snapshot, plus the advanced +// infrastructure testkit contract (gRPC-Web proxy, Servlet container, xDS control-plane failure). +dependencies { + api project(':grpc:grpc-core-api') + api project(':grpc:grpc-client') + api project(':grpc-advanced:grpc-advanced-bootstrap') +} diff --git a/src/grpc-advanced/grpc-advanced-diagnostics/gradle.lockfile b/src/grpc-advanced/grpc-advanced-diagnostics/gradle.lockfile new file mode 100644 index 00000000..9369e831 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-diagnostics/gradle.lockfile @@ -0,0 +1,91 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty= diff --git a/src/grpc-advanced/grpc-advanced-diagnostics/src/main/java/dev/caskeleton/grpc/advanced/diagnostics/GrpcAdvancedInfrastructureTestkit.java b/src/grpc-advanced/grpc-advanced-diagnostics/src/main/java/dev/caskeleton/grpc/advanced/diagnostics/GrpcAdvancedInfrastructureTestkit.java new file mode 100644 index 00000000..53a0a5d5 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-diagnostics/src/main/java/dev/caskeleton/grpc/advanced/diagnostics/GrpcAdvancedInfrastructureTestkit.java @@ -0,0 +1,87 @@ +package dev.caskeleton.grpc.advanced.diagnostics; + +import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +/** + * What an advanced capability has to be exercised against before it counts as verified. + * + *

Real infrastructure, named per capability. gRPC-Web without a proxy tests a code path no + * browser will take; a Servlet profile without a container tests the profile object; xDS without a + * control plane cannot exercise the case that matters, which is the control plane going away. In + * all three, a suite that runs without the infrastructure passes and establishes nothing, which is + * worse than not having one. + */ +public final class GrpcAdvancedInfrastructureTestkit { + + private GrpcAdvancedInfrastructureTestkit() {} + + /** A piece of infrastructure a capability's suite needs. */ + public enum Infrastructure { + /** An Envoy or equivalent gRPC-Web proxy. */ + GRPC_WEB_PROXY, + /** A Servlet container serving HTTP/2. */ + SERVLET_CONTAINER, + /** An xDS control plane that can be stopped. */ + XDS_CONTROL_PLANE, + /** A Kotlin toolchain. */ + KOTLIN_TOOLCHAIN + } + + /** What {@code capability} needs before its evidence means anything. */ + public static Set requiredFor(GrpcAdvancedCapability capability) { + if (capability == null) { + throw new IllegalArgumentException("a capability is required"); + } + return switch (capability) { + case GRPC_WEB -> Set.of(Infrastructure.GRPC_WEB_PROXY); + case SERVLET_COMPAT -> Set.of(Infrastructure.SERVLET_CONTAINER); + case XDS -> Set.of(Infrastructure.XDS_CONTROL_PLANE); + case KOTLIN -> Set.of(Infrastructure.KOTLIN_TOOLCHAIN); + case EDITION_2024, + EDITION_2026, + CLIENT_STREAMING, + BIDI_STREAMING, + MANUAL_FLOW_CONTROL, + HEDGING, + CUSTOM_RESOLVER, + CUSTOM_LOAD_BALANCER, + INTEGRATION_BRIDGE, + REACTOR, + CHANNEL_DIAGNOSTICS -> + Set.of(); + }; + } + + /** + * Every piece of infrastructure a capability needs and does not have. + * + * @return an empty list when the suite can produce evidence that means something + */ + public static List missingInfrastructure( + GrpcAdvancedCapability capability, Set available) { + if (available == null) { + throw new IllegalArgumentException("the available infrastructure set is required"); + } + Set required = requiredFor(capability); + if (required.isEmpty()) { + return List.of(); + } + Set missing = EnumSet.copyOf(required); + missing.removeAll(available); + List gaps = new ArrayList<>(); + missing.stream() + .sorted() + .forEach( + infrastructure -> + gaps.add( + capability.flagName() + + " needs " + + infrastructure + + "; a suite that runs without it passes and establishes nothing")); + return List.copyOf(gaps); + } +} diff --git a/src/grpc-advanced/grpc-advanced-diagnostics/src/main/java/dev/caskeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsPolicy.java b/src/grpc-advanced/grpc-advanced-diagnostics/src/main/java/dev/caskeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsPolicy.java new file mode 100644 index 00000000..2ff22e4a --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-diagnostics/src/main/java/dev/caskeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsPolicy.java @@ -0,0 +1,55 @@ +package dev.caskeleton.grpc.advanced.diagnostics; + +import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability; +import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedFeatureFlags; +import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedModuleGuard; +import java.util.Set; + +/** + * Who may read diagnostics, and which services are registered at all. + * + *

CSDS is registered only when xDS is enabled. A CSDS service on a deployment that does not use + * xDS answers every query with nothing, which is harmless, and advertises a control-plane surface + * that does not exist, which is not: it is one more endpoint to scan and one more thing whose + * absence of authorization nobody notices. + */ +public record GrpcChannelDiagnosticsPolicy(Set adminNetworks, Set adminRoles) { + + /** Refuses a policy with only one gate. */ + public GrpcChannelDiagnosticsPolicy { + if (adminNetworks == null || adminRoles == null) { + throw new IllegalArgumentException("a diagnostics policy states both gates"); + } + adminNetworks = Set.copyOf(adminNetworks); + adminRoles = Set.copyOf(adminRoles); + if (adminNetworks.isEmpty() || adminRoles.isEmpty()) { + throw new IllegalArgumentException( + "diagnostics need both a network and a role gate; Channelz holds every socket's peer and " + + "security detail, so either gate alone is the whole surface"); + } + } + + /** This repository's default. */ + public static GrpcChannelDiagnosticsPolicy standard() { + return new GrpcChannelDiagnosticsPolicy(Set.of("admin"), Set.of("ROLE_PLATFORM_ADMIN")); + } + + /** Whether this caller may read a snapshot. */ + public boolean mayRead(String callerNetwork, Set callerRoles) { + return callerNetwork != null + && adminNetworks.contains(callerNetwork) + && callerRoles != null + && callerRoles.stream().anyMatch(adminRoles::contains); + } + + /** Whether the Channelz service should be registered. */ + public static boolean registerChannelz(GrpcAdvancedFeatureFlags flags) { + return GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.CHANNEL_DIAGNOSTICS); + } + + /** Whether the CSDS service should be registered. Only when xDS is actually in use. */ + public static boolean registerCsds(GrpcAdvancedFeatureFlags flags) { + return registerChannelz(flags) + && GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.XDS); + } +} diff --git a/src/grpc-advanced/grpc-advanced-diagnostics/src/main/java/dev/caskeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsSnapshot.java b/src/grpc-advanced/grpc-advanced-diagnostics/src/main/java/dev/caskeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsSnapshot.java new file mode 100644 index 00000000..a99baef3 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-diagnostics/src/main/java/dev/caskeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsSnapshot.java @@ -0,0 +1,63 @@ +package dev.caskeleton.grpc.advanced.diagnostics; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * What Channelz and CSDS report, after redaction. + * + *

Counters and states rather than call contents. The questions a diagnostics endpoint exists to + * answer — which subchannels are connected, how many calls are in flight, which xDS resources the + * control plane last sent — are all answerable from aggregates, and aggregates cannot leak a + * caller's data. + */ +public record GrpcChannelDiagnosticsSnapshot( + String channelProfile, + String connectivityState, + int subchannelCount, + long callsStarted, + long callsSucceeded, + long callsFailed, + List maskedSocketAddresses, + Map xdsResourceVersions, + Instant capturedAt) { + + /** Refuses a snapshot carrying something it should not. */ + public GrpcChannelDiagnosticsSnapshot { + if (channelProfile == null || channelProfile.isBlank()) { + throw new IllegalArgumentException("a diagnostics snapshot names its channel profile"); + } + if (connectivityState == null || connectivityState.isBlank()) { + throw new IllegalArgumentException("a diagnostics snapshot carries a connectivity state"); + } + if (subchannelCount < 0 || callsStarted < 0 || callsSucceeded < 0 || callsFailed < 0) { + throw new IllegalArgumentException("diagnostics counters must not be negative"); + } + if (maskedSocketAddresses == null || xdsResourceVersions == null || capturedAt == null) { + throw new IllegalArgumentException("every snapshot section must be present"); + } + maskedSocketAddresses = List.copyOf(maskedSocketAddresses); + xdsResourceVersions = Map.copyOf(xdsResourceVersions); + + maskedSocketAddresses.stream() + .filter(address -> !address.equals(GrpcDiagnosticsRedactor.maskAddress(address))) + .findFirst() + .ifPresent( + unmasked -> { + throw new IllegalArgumentException( + "socket address '" + + unmasked + + "' is not masked; a diagnostics endpoint that publishes peer addresses " + + "publishes every tenant's connection"); + }); + xdsResourceVersions.keySet().stream() + .filter(GrpcDiagnosticsRedactor::forbiddenField) + .findFirst() + .ifPresent( + forbidden -> { + throw new IllegalArgumentException( + "field '" + forbidden + "' may not appear in a diagnostics snapshot"); + }); + } +} diff --git a/src/grpc-advanced/grpc-advanced-diagnostics/src/main/java/dev/caskeleton/grpc/advanced/diagnostics/GrpcDiagnosticsRedactor.java b/src/grpc-advanced/grpc-advanced-diagnostics/src/main/java/dev/caskeleton/grpc/advanced/diagnostics/GrpcDiagnosticsRedactor.java new file mode 100644 index 00000000..b91a7db2 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-diagnostics/src/main/java/dev/caskeleton/grpc/advanced/diagnostics/GrpcDiagnosticsRedactor.java @@ -0,0 +1,72 @@ +package dev.caskeleton.grpc.advanced.diagnostics; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Removes from a diagnostics snapshot everything that is not a diagnostic. + * + *

Channelz is unusually dangerous to expose because it is genuinely useful: it holds every + * socket's local and remote address, the security details of each connection, and per-call state. + * An administrator debugging a routing problem needs the shape of that; nobody needs the peer + * addresses of every tenant's connection, and once the endpoint exists the whole of it is one + * authorization mistake away from being readable. + * + *

Addresses are masked rather than dropped. An operator has to be able to tell two subchannels + * apart, and a stable mask does that without publishing where they point. + */ +public final class GrpcDiagnosticsRedactor { + + private static final Pattern SENSITIVE_FIELD = + Pattern.compile( + "(?i).*(authorization|bearer|password|secret|token|private[_-]?key|certificate|" + + "credential|payload|metadata).*"); + + private static final Pattern IPV4_WITH_PORT = + Pattern.compile("\\b(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(:\\d{1,5})?\\b"); + + private GrpcDiagnosticsRedactor() {} + + /** Whether a field name may appear in a snapshot at all. */ + public static boolean forbiddenField(String fieldName) { + return fieldName != null && SENSITIVE_FIELD.matcher(fieldName).matches(); + } + + /** + * Masks an address so two of them stay distinguishable without being resolvable. + * + *

The last two octets go; the first two stay, because "which subnet" is a real diagnostic + * question and "which host" is not one the diagnostics endpoint should answer. + */ + public static String maskAddress(String address) { + if (address == null || address.isBlank()) { + return "unknown"; + } + return IPV4_WITH_PORT + .matcher(address) + .replaceAll(matchResult -> matchResult.group(1) + "." + matchResult.group(2) + ".x.x"); + } + + /** + * A snapshot map with forbidden fields removed and addresses masked. + * + * @param addressFields which keys hold addresses, since a mask applied to everything would mangle + * version strings and counters + */ + public static Map redact( + Map raw, java.util.Set addressFields) { + if (raw == null || addressFields == null) { + throw new IllegalArgumentException("redaction needs the map and the address field set"); + } + Map redacted = new LinkedHashMap<>(); + raw.forEach( + (key, value) -> { + if (forbiddenField(key)) { + return; + } + redacted.put(key, addressFields.contains(key) ? maskAddress(value) : value); + }); + return Map.copyOf(redacted); + } +} diff --git a/src/grpc-advanced/grpc-advanced-diagnostics/src/test/java/dev/caskeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsPolicyTest.java b/src/grpc-advanced/grpc-advanced-diagnostics/src/test/java/dev/caskeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsPolicyTest.java new file mode 100644 index 00000000..8f2da8c9 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-diagnostics/src/test/java/dev/caskeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsPolicyTest.java @@ -0,0 +1,229 @@ +package dev.caskeleton.grpc.advanced.diagnostics; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability; +import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedFeatureFlags; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcChannelDiagnosticsPolicyTest { + + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + + @Test + @DisplayName("diagnostics need both an admin network and an admin role") + void diagnosticsNeedBothGates() { + GrpcChannelDiagnosticsPolicy policy = GrpcChannelDiagnosticsPolicy.standard(); + + assertThat(policy.mayRead("admin", Set.of("ROLE_PLATFORM_ADMIN"))).isTrue(); + assertThat(policy.mayRead("public", Set.of("ROLE_PLATFORM_ADMIN"))).isFalse(); + assertThat(policy.mayRead("admin", Set.of("ROLE_USER"))).isFalse(); + assertThatThrownBy(() -> new GrpcChannelDiagnosticsPolicy(Set.of("admin"), Set.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("either gate alone is the whole surface"); + } + + @Test + @DisplayName("CSDS is registered only when xDS is actually enabled") + void csdsFollowsXds() { + GrpcAdvancedFeatureFlags diagnosticsOnly = + GrpcAdvancedFeatureFlags.forDevelopment() + .enable(GrpcAdvancedCapability.CHANNEL_DIAGNOSTICS); + GrpcAdvancedFeatureFlags withXds = + GrpcAdvancedFeatureFlags.forDevelopment() + .enable(GrpcAdvancedCapability.CHANNEL_DIAGNOSTICS) + .enable(GrpcAdvancedCapability.XDS); + + assertThat(GrpcChannelDiagnosticsPolicy.registerChannelz(diagnosticsOnly)).isTrue(); + assertThat(GrpcChannelDiagnosticsPolicy.registerCsds(diagnosticsOnly)).isFalse(); + assertThat(GrpcChannelDiagnosticsPolicy.registerCsds(withXds)).isTrue(); + assertThat( + GrpcChannelDiagnosticsPolicy.registerChannelz( + GrpcAdvancedFeatureFlags.forDevelopment())) + .isFalse(); + } + + @Test + @DisplayName("credential, certificate, metadata and payload fields are dropped outright") + void sensitiveFieldsAreDropped() { + Map raw = new LinkedHashMap<>(); + raw.put("connectivityState", "READY"); + raw.put("authorization", "Bearer abc"); + raw.put("peerCertificate", "-----BEGIN CERTIFICATE-----"); + raw.put("lastCallMetadata", "x-tenant=acme"); + raw.put("requestPayload", "{...}"); + + Map redacted = GrpcDiagnosticsRedactor.redact(raw, Set.of()); + + assertThat(redacted).containsOnlyKeys("connectivityState"); + assertThat(GrpcDiagnosticsRedactor.forbiddenField("privateKeyRef")).isTrue(); + assertThat(GrpcDiagnosticsRedactor.forbiddenField("subchannelCount")).isFalse(); + } + + @Test + @DisplayName("addresses are masked so two subchannels stay distinguishable but unresolvable") + void addressesAreMaskedRatherThanDropped() { + assertThat(GrpcDiagnosticsRedactor.maskAddress("10.4.13.201:9090")).isEqualTo("10.4.x.x"); + assertThat(GrpcDiagnosticsRedactor.maskAddress("10.9.13.201")).isEqualTo("10.9.x.x"); + assertThat(GrpcDiagnosticsRedactor.maskAddress(null)).isEqualTo("unknown"); + + Map redacted = + GrpcDiagnosticsRedactor.redact( + Map.of("remoteAddress", "10.4.13.201:9090"), Set.of("remoteAddress")); + assertThat(redacted).containsEntry("remoteAddress", "10.4.x.x"); + } + + @Test + @DisplayName("a snapshot carrying an unmasked address is refused") + void anUnmaskedAddressIsRefused() { + assertThatThrownBy( + () -> + new GrpcChannelDiagnosticsSnapshot( + "documents-read", + "READY", + 3, + 100L, + 98L, + 2L, + List.of("10.4.13.201:9090"), + Map.of(), + NOW)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("every tenant's connection"); + } + + @Test + @DisplayName("a snapshot carries counters and states, and is accepted when masked") + void aMaskedSnapshotIsAccepted() { + GrpcChannelDiagnosticsSnapshot snapshot = + new GrpcChannelDiagnosticsSnapshot( + "documents-read", + "READY", + 3, + 100L, + 98L, + 2L, + List.of("10.4.x.x", "10.5.x.x"), + Map.of("documents-cluster", "v7"), + NOW); + + assertThat(snapshot.subchannelCount()).isEqualTo(3); + assertThat(snapshot.xdsResourceVersions()).containsEntry("documents-cluster", "v7"); + assertThat(snapshot.capturedAt()).isEqualTo(NOW); + } + + @Test + @DisplayName("a snapshot carrying a forbidden field name is refused") + void aForbiddenFieldNameIsRefused() { + assertThatThrownBy( + () -> + new GrpcChannelDiagnosticsSnapshot( + "documents-read", + "READY", + 1, + 1L, + 1L, + 0L, + List.of(), + Map.of("controlPlaneToken", "abc"), + NOW)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("may not appear in a diagnostics snapshot"); + } + + @Test + @DisplayName("the committed CSDS fixture is redacted into something publishable") + void theCommittedCsdsFixtureIsRedacted() { + String raw = resource("xds/control-plane-snapshot.json"); + + // The fixture deliberately contains the three shapes a snapshot must never carry, so the + // redactor is exercised against data shaped like the real thing. + assertThat(raw) + .contains("controlPlaneToken") + .contains("peerCertificate") + .contains("10.4.13.201:9090"); + + Map fields = new LinkedHashMap<>(); + fields.put("connectivityState", "READY"); + fields.put("subchannelCount", "3"); + fields.put("controlPlaneToken", "must-not-appear-in-a-snapshot"); + fields.put("peerCertificate", "-----BEGIN CERTIFICATE-----"); + fields.put("lastCallMetadata", "x-tenant=acme"); + fields.put("remoteAddress", "10.4.13.201:9090"); + + Map redacted = GrpcDiagnosticsRedactor.redact(fields, Set.of("remoteAddress")); + + assertThat(redacted).containsOnlyKeys("connectivityState", "subchannelCount", "remoteAddress"); + assertThat(redacted.get("remoteAddress")).isEqualTo("10.4.x.x"); + assertThat(String.join("|", redacted.values())).doesNotContain("must-not-appear-in-a-snapshot"); + } + + @Test + @DisplayName("a snapshot built from the fixture's resource versions is accepted") + void aSnapshotFromTheFixtureIsAccepted() { + assertThat(resource("xds/control-plane-snapshot.json")).contains("documents-cluster"); + + GrpcChannelDiagnosticsSnapshot snapshot = + new GrpcChannelDiagnosticsSnapshot( + "documents-read", + "READY", + 3, + 100L, + 98L, + 2L, + List.of("10.4.x.x", "10.4.x.x", "10.5.x.x"), + Map.of("documents-cluster", "v7", "documents-route", "v7", "documents-listener", "v6"), + NOW); + + assertThat(snapshot.xdsResourceVersions()).hasSize(3); + } + + @Test + @DisplayName("each capability names the real infrastructure its evidence depends on") + void capabilitiesNameTheirInfrastructure() { + assertThat(GrpcAdvancedInfrastructureTestkit.requiredFor(GrpcAdvancedCapability.GRPC_WEB)) + .containsExactly(GrpcAdvancedInfrastructureTestkit.Infrastructure.GRPC_WEB_PROXY); + assertThat(GrpcAdvancedInfrastructureTestkit.requiredFor(GrpcAdvancedCapability.XDS)) + .containsExactly(GrpcAdvancedInfrastructureTestkit.Infrastructure.XDS_CONTROL_PLANE); + assertThat(GrpcAdvancedInfrastructureTestkit.requiredFor(GrpcAdvancedCapability.HEDGING)) + .isEmpty(); + } + + @Test + @DisplayName("a suite without its infrastructure is reported as establishing nothing") + void missingInfrastructureIsReported() { + assertThat( + GrpcAdvancedInfrastructureTestkit.missingInfrastructure( + GrpcAdvancedCapability.XDS, Set.of())) + .singleElement() + .satisfies(gap -> assertThat(gap).contains("establishes nothing")); + assertThat( + GrpcAdvancedInfrastructureTestkit.missingInfrastructure( + GrpcAdvancedCapability.XDS, + Set.of(GrpcAdvancedInfrastructureTestkit.Infrastructure.XDS_CONTROL_PLANE))) + .isEmpty(); + assertThat( + GrpcAdvancedInfrastructureTestkit.missingInfrastructure( + GrpcAdvancedCapability.HEDGING, Set.of())) + .isEmpty(); + } + + private static String resource(String path) { + try (java.io.InputStream stream = + GrpcChannelDiagnosticsPolicyTest.class.getClassLoader().getResourceAsStream(path)) { + if (stream == null) { + throw new IllegalStateException("missing test resource " + path); + } + return new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } catch (java.io.IOException e) { + throw new java.io.UncheckedIOException(e); + } + } +} diff --git a/src/grpc-advanced/grpc-advanced-diagnostics/src/test/resources/xds/control-plane-snapshot.json b/src/grpc-advanced/grpc-advanced-diagnostics/src/test/resources/xds/control-plane-snapshot.json new file mode 100644 index 00000000..ab5db922 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-diagnostics/src/test/resources/xds/control-plane-snapshot.json @@ -0,0 +1,24 @@ +{ + "_comment": [ + "What CSDS reports for one client, as the diagnostics layer receives it before redaction.", + "The fixture deliberately contains three things a snapshot must not publish - a control-plane", + "token, a peer certificate and a raw socket address - so the redactor is tested against data", + "shaped like the real thing rather than against a string somebody invented for the assertion." + ], + "version_info": "v7", + "resources": { + "documents-cluster": "v7", + "documents-route": "v7", + "documents-listener": "v6" + }, + "connectivityState": "READY", + "subchannelCount": 3, + "sockets": [ + { "remoteAddress": "10.4.13.201:9090", "state": "READY" }, + { "remoteAddress": "10.4.19.87:9090", "state": "READY" }, + { "remoteAddress": "10.5.2.44:9090", "state": "TRANSIENT_FAILURE" } + ], + "controlPlaneToken": "must-not-appear-in-a-snapshot", + "peerCertificate": "-----BEGIN CERTIFICATE----- must-not-appear-in-a-snapshot", + "lastCallMetadata": "x-tenant=acme" +} diff --git a/src/grpc-advanced/grpc-advanced-edition/build.gradle b/src/grpc-advanced/grpc-advanced-edition/build.gradle new file mode 100644 index 00000000..991aec15 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-edition/build.gradle @@ -0,0 +1,10 @@ +apply plugin: 'java-library' + +// Protobuf Edition lanes. Edition 2024 is an opt-in Advanced lane that must produce cross-consumer +// compile evidence before anything public moves onto it; Edition 2026 is a watch lane that records +// release/toolchain status and is refused as a Stable contract source. +dependencies { + api project(':grpc:grpc-core-api') + api project(':grpc:grpc-proto-contract') + api project(':grpc-advanced:grpc-advanced-bootstrap') +} diff --git a/src/grpc-advanced/grpc-advanced-edition/gradle.lockfile b/src/grpc-advanced/grpc-advanced-edition/gradle.lockfile new file mode 100644 index 00000000..e2c95854 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-edition/gradle.lockfile @@ -0,0 +1,84 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty=compileClasspath,runtimeClasspath diff --git a/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2024Gate.java b/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2024Gate.java new file mode 100644 index 00000000..45d44d06 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2024Gate.java @@ -0,0 +1,61 @@ +package dev.caskeleton.grpc.advanced.edition; + +import java.util.ArrayList; +import java.util.List; + +/** + * Decides whether Edition 2024 may be adopted, and keeps its failures out of the Stable release. + * + *

The separation the Stable plan asks for, in one place: an Edition 2024 failure blocks the + * edition's own promotion and does not block a proto3 release. Without that split, an opt-in lane + * that nobody depends on can hold up every release, and the first response to that is to stop + * running the lane. + */ +public final class GrpcEdition2024Gate { + + private GrpcEdition2024Gate() {} + + /** + * Every reason Edition 2024 may not be promoted. + * + * @return an empty list when the edition is ready to adopt + */ + public static List promotionBlockers( + GrpcEditionCompatibilityReport report, + boolean consumerMigrationPlanned, + boolean promotionAdr) { + if (report == null) { + throw new IllegalArgumentException("a compatibility report is required"); + } + List blockers = new ArrayList<>(report.incompatibilities()); + if (!consumerMigrationPlanned) { + blockers.add( + "no consumer migration is planned; moving a public service to an edition breaks whichever " + + "consumer's generator treats its features differently"); + } + if (!promotionAdr) { + blockers.add( + "no promotion ADR records the decision to move onto Edition " + + GrpcEdition2024Policy.EDITION); + } + return List.copyOf(blockers); + } + + /** + * Whether an Edition 2024 failure blocks a proto3 Stable release. + * + *

Always false. Stated as a method so the property is tested rather than described. + */ + public static boolean blocksStableRelease() { + return false; + } + + /** + * Whether an Edition 2024 failure blocks the edition's own promotion. + * + *

Always true, for the same reason. + */ + public static boolean blocksEditionPromotion() { + return true; + } +} diff --git a/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2024Policy.java b/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2024Policy.java new file mode 100644 index 00000000..0ec5f0dd --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2024Policy.java @@ -0,0 +1,47 @@ +package dev.caskeleton.grpc.advanced.edition; + +import java.util.Set; + +/** + * Where Edition 2024 may be used, and where it may not. + * + *

Module-level opt-in, and never for a public service without a promotion decision. The reason + * is that an edition change is invisible to the schema's owner and consequential for its consumers: + * the wire bytes are usually identical, so nothing fails locally, and the breakage appears in + * whichever consumer's generator handles the edition's features differently. + */ +public record GrpcEdition2024Policy( + Set optedInModules, Set publicServices, boolean promotionApproved) { + + /** The edition this policy governs. */ + public static final String EDITION = "2024"; + + /** Copies both sets and refuses an approval nobody recorded. */ + public GrpcEdition2024Policy { + if (optedInModules == null || publicServices == null) { + throw new IllegalArgumentException("an edition policy states both sets"); + } + optedInModules = Set.copyOf(optedInModules); + publicServices = Set.copyOf(publicServices); + } + + /** The default: nothing opted in, no promotion. */ + public static GrpcEdition2024Policy notAdopted() { + return new GrpcEdition2024Policy(Set.of(), Set.of(), false); + } + + /** Whether {@code moduleId} may use Edition 2024 schema sources. */ + public boolean allowedIn(String moduleId) { + return optedInModules.contains(moduleId); + } + + /** + * Whether {@code serviceName} may move onto Edition 2024. + * + *

False for any public service until a promotion is approved, regardless of module opt-in: the + * opt-in is a build decision and the promotion is a consumer-migration decision. + */ + public boolean serviceMayMove(String serviceName) { + return !publicServices.contains(serviceName) || promotionApproved; + } +} diff --git a/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2026Guard.java b/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2026Guard.java new file mode 100644 index 00000000..3b9aab07 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2026Guard.java @@ -0,0 +1,48 @@ +package dev.caskeleton.grpc.advanced.edition; + +/** + * Refuses Edition 2026 as a schema source, whatever a report says. + * + *

The guard is deliberately not conditional on the watch report. A watch lane's purpose is to + * record what is true, and letting the same record also authorise use means the moment somebody + * marks four fields SUPPORTED, a schema can move onto an edition with no promotion decision, no + * consumer migration and no ADR. Turning the watch into a lane that can be used is a code change + * here, and that is the point. + */ +public final class GrpcEdition2026Guard { + + private GrpcEdition2026Guard() {} + + /** Whether Edition 2026 may be used as a Stable contract source. Always false. */ + public static boolean allowedAsStableSource() { + return false; + } + + /** + * Fails when Edition 2026 is used as a schema source. + * + * @throws IllegalStateException naming what is still outstanding, so the refusal is actionable + */ + public static void requireNotUsedAsSource(GrpcEdition2026WatchReport report) { + if (report == null) { + throw new IllegalArgumentException("a watch report is required"); + } + throw new IllegalStateException( + "Edition " + + GrpcEdition2026WatchReport.EDITION + + " is a watch lane, not a schema source" + + (report.outstanding().isEmpty() + ? "; every toolchain gate is satisfied, so the next step is a promotion decision " + + "rather than a schema change" + : "; outstanding: " + report.outstanding())); + } + + /** + * Whether an Edition 2026 CI failure blocks the Stable build. + * + *

False. A watch lane that can break the build is a watch lane somebody deletes. + */ + public static boolean blocksStableBuild() { + return false; + } +} diff --git a/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2026Status.java b/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2026Status.java new file mode 100644 index 00000000..2e034498 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2026Status.java @@ -0,0 +1,28 @@ +package dev.caskeleton.grpc.advanced.edition; + +/** + * The four independent things that have to be true before an edition is usable, tracked separately. + * + *

Separately, because they land at different times and in different projects. An edition can be + * released by the specification while {@code protoc} does not emit it, or emitted while Buf cannot + * lint it, or lintable while the Java runtime does not implement its features. A single "supported + * yes/no" flag collapses four different waiting states into one, and the answer to "what are we + * waiting for" is then nobody's. + */ +public enum GrpcEdition2026Status { + /** Nothing is known yet. */ + UNKNOWN, + /** Announced or drafted, not released. */ + DRAFT, + /** Released by the specification. */ + RELEASED, + /** Supported by the toolchain component in question. */ + SUPPORTED, + /** Explicitly not supported, and not expected to be. */ + UNSUPPORTED; + + /** Whether this status permits use. */ + public boolean usable() { + return this == SUPPORTED; + } +} diff --git a/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2026WatchReport.java b/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2026WatchReport.java new file mode 100644 index 00000000..719bd707 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2026WatchReport.java @@ -0,0 +1,70 @@ +package dev.caskeleton.grpc.advanced.edition; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** + * What is currently true about Edition 2026 across the four things that gate it. + * + *

Recorded with a date. A watch report without one is indistinguishable from a stale note, and + * the whole purpose of a watch lane is to be re-read later by somebody deciding whether the wait is + * over. + */ +public record GrpcEdition2026WatchReport( + GrpcEdition2026Status specificationStatus, + GrpcEdition2026Status protocStatus, + GrpcEdition2026Status bufStatus, + GrpcEdition2026Status javaRuntimeStatus, + Instant observedAt) { + + /** The edition this report tracks. */ + public static final String EDITION = "2026"; + + /** Requires every status and a date. */ + public GrpcEdition2026WatchReport { + if (specificationStatus == null + || protocStatus == null + || bufStatus == null + || javaRuntimeStatus == null) { + throw new IllegalArgumentException("a watch report records all four statuses"); + } + if (observedAt == null) { + throw new IllegalArgumentException( + "a watch report is dated; without a date it cannot be told from a stale note"); + } + } + + /** Nothing known yet, as of {@code observedAt}. */ + public static GrpcEdition2026WatchReport nothingKnown(Instant observedAt) { + return new GrpcEdition2026WatchReport( + GrpcEdition2026Status.UNKNOWN, + GrpcEdition2026Status.UNKNOWN, + GrpcEdition2026Status.UNKNOWN, + GrpcEdition2026Status.UNKNOWN, + observedAt); + } + + /** What is still missing, named. */ + public List outstanding() { + List waiting = new ArrayList<>(); + if (!specificationStatus.usable()) { + waiting.add("specification is " + specificationStatus); + } + if (!protocStatus.usable()) { + waiting.add("protoc support is " + protocStatus); + } + if (!bufStatus.usable()) { + waiting.add("Buf support is " + bufStatus); + } + if (!javaRuntimeStatus.usable()) { + waiting.add("Java runtime support is " + javaRuntimeStatus); + } + return List.copyOf(waiting); + } + + /** Whether every gate is satisfied. */ + public boolean readyToEvaluate() { + return outstanding().isEmpty(); + } +} diff --git a/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEditionCompatibilityReport.java b/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEditionCompatibilityReport.java new file mode 100644 index 00000000..4b655f8d --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-edition/src/main/java/dev/caskeleton/grpc/advanced/edition/GrpcEditionCompatibilityReport.java @@ -0,0 +1,72 @@ +package dev.caskeleton.grpc.advanced.edition; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * How an Edition schema compares with its proto3 twin, per consumer toolchain. + * + *

Three comparisons rather than one. Wire compatibility says stored and in-flight messages keep + * decoding; JSON compatibility says a REST transcoder or a browser client keeps working; source + * compatibility says the generated code still compiles. An edition migration can preserve the first + * two and break the third for a language whose generator handles the edition's features differently + * — which is exactly the failure this lane exists to find before a public service moves. + */ +public record GrpcEditionCompatibilityReport( + String editionName, + boolean wireCompatible, + boolean jsonCompatible, + Map sourceCompatibleByToolchain) { + + /** Requires an edition and at least one toolchain result. */ + public GrpcEditionCompatibilityReport { + if (editionName == null || editionName.isBlank()) { + throw new IllegalArgumentException("a compatibility report names its edition"); + } + if (sourceCompatibleByToolchain == null || sourceCompatibleByToolchain.isEmpty()) { + throw new IllegalArgumentException( + "a report with no toolchain result compares nothing; Java alone is not cross-language " + + "evidence"); + } + sourceCompatibleByToolchain = Map.copyOf(sourceCompatibleByToolchain); + } + + /** The toolchains whose generated code stopped compiling. */ + public Set brokenToolchains() { + return sourceCompatibleByToolchain.entrySet().stream() + .filter(entry -> !entry.getValue()) + .map(Map.Entry::getKey) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + } + + /** Every incompatibility, described. */ + public List incompatibilities() { + List problems = new ArrayList<>(); + if (!wireCompatible) { + problems.add( + editionName + " is not wire-compatible with proto3; stored messages would break"); + } + if (!jsonCompatible) { + problems.add( + editionName + + " is not JSON-compatible with proto3; transcoded and browser clients would break"); + } + brokenToolchains().stream() + .sorted() + .forEach( + toolchain -> + problems.add( + editionName + + " generated source does not compile for toolchain '" + + toolchain + + "'")); + return List.copyOf(problems); + } + + /** Whether every comparison passed. */ + public boolean fullyCompatible() { + return incompatibilities().isEmpty(); + } +} diff --git a/src/grpc-advanced/grpc-advanced-edition/src/main/resources/proto/edition2024/compatibility.proto b/src/grpc-advanced/grpc-advanced-edition/src/main/resources/proto/edition2024/compatibility.proto new file mode 100644 index 00000000..40679ad8 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-edition/src/main/resources/proto/edition2024/compatibility.proto @@ -0,0 +1,28 @@ +edition = "2024"; + +package hyeonworks.grpc.edition.v1; + +option java_multiple_files = true; +option java_package = "hyeonworks.grpc.edition.v1.generated"; + +// The Edition 2024 comparison fixture. +// +// It exists to be compiled beside its proto3 twin and compared: same fields, same numbers, same JSON +// names, with presence expressed by the edition's features rather than by `optional`. The lane's +// question is whether the two produce the same wire bytes and the same JSON, and answering it needs +// both files to exist. +// +// Not a Stable contract source. No public service moves onto an edition until the lane has produced +// cross-consumer compile evidence and a promotion ADR — see GrpcEdition2024Gate. +message DocumentSummary { + string id = 1; + string title = 2 [features.field_presence = EXPLICIT]; + int64 revision = 3; + repeated string labels = 4; +} + +enum DocumentState { + DOCUMENT_STATE_UNSPECIFIED = 0; + DOCUMENT_STATE_DRAFT = 1; + DOCUMENT_STATE_PUBLISHED = 2; +} diff --git a/src/grpc-advanced/grpc-advanced-edition/src/test/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2024GateTest.java b/src/grpc-advanced/grpc-advanced-edition/src/test/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2024GateTest.java new file mode 100644 index 00000000..e13d8399 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-edition/src/test/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2024GateTest.java @@ -0,0 +1,118 @@ +package dev.caskeleton.grpc.advanced.edition; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcEdition2024GateTest { + + private static GrpcEditionCompatibilityReport report( + boolean wire, boolean json, Map toolchains) { + return new GrpcEditionCompatibilityReport("2024", wire, json, toolchains); + } + + @Test + @DisplayName("Edition 2024 is module-level opt-in, and nothing is opted in by default") + void editionIsOptInPerModule() { + GrpcEdition2024Policy policy = GrpcEdition2024Policy.notAdopted(); + + assertThat(policy.allowedIn("grpc-proto-contract")).isFalse(); + assertThat( + new GrpcEdition2024Policy(Set.of("grpc-advanced-edition"), Set.of(), false) + .allowedIn("grpc-advanced-edition")) + .isTrue(); + } + + @Test + @DisplayName("a public service may not move onto the edition without a promotion") + void publicServicesNeedAPromotion() { + GrpcEdition2024Policy withoutPromotion = + new GrpcEdition2024Policy( + Set.of("grpc-advanced-edition"), + Set.of("hyeonworks.document.v1.DocumentService"), + false); + GrpcEdition2024Policy withPromotion = + new GrpcEdition2024Policy( + Set.of("grpc-advanced-edition"), + Set.of("hyeonworks.document.v1.DocumentService"), + true); + + assertThat(withoutPromotion.serviceMayMove("hyeonworks.document.v1.DocumentService")).isFalse(); + assertThat(withPromotion.serviceMayMove("hyeonworks.document.v1.DocumentService")).isTrue(); + assertThat(withoutPromotion.serviceMayMove("hyeonworks.internal.v1.ScratchService")).isTrue(); + } + + @Test + @DisplayName("Java compiling alone is not cross-language evidence") + void javaAloneIsNotEvidence() { + GrpcEditionCompatibilityReport javaOnlyBroken = + report(true, true, Map.of("java", true, "go", false, "python", true)); + + assertThat(javaOnlyBroken.fullyCompatible()).isFalse(); + assertThat(javaOnlyBroken.brokenToolchains()).containsExactly("go"); + assertThatThrownBy(() -> report(true, true, Map.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Java alone is not cross-language"); + } + + @Test + @DisplayName("wire, JSON and source compatibility are compared separately") + void threeComparisonsAreSeparate() { + assertThat(report(false, true, Map.of("java", true)).incompatibilities()) + .singleElement() + .satisfies(problem -> assertThat(problem).contains("stored messages")); + assertThat(report(true, false, Map.of("java", true)).incompatibilities()) + .singleElement() + .satisfies(problem -> assertThat(problem).contains("browser clients")); + } + + @Test + @DisplayName("promotion needs compatibility, a consumer migration and an ADR") + void promotionNeedsAllThree() { + GrpcEditionCompatibilityReport clean = report(true, true, Map.of("java", true, "go", true)); + + assertThat(GrpcEdition2024Gate.promotionBlockers(clean, true, true)).isEmpty(); + assertThat(GrpcEdition2024Gate.promotionBlockers(clean, false, true)) + .anySatisfy(blocker -> assertThat(blocker).contains("consumer migration")); + assertThat(GrpcEdition2024Gate.promotionBlockers(clean, true, false)) + .anySatisfy(blocker -> assertThat(blocker).contains("promotion ADR")); + } + + @Test + @DisplayName("an Edition failure blocks the edition's promotion and not a proto3 release") + void editionFailuresAreIsolatedFromStable() { + assertThat(GrpcEdition2024Gate.blocksStableRelease()).isFalse(); + assertThat(GrpcEdition2024Gate.blocksEditionPromotion()).isTrue(); + } + + @Test + @DisplayName("the Edition 2024 comparison fixture ships beside its proto3 twin") + void theComparisonFixtureShips() { + String source = resource("proto/edition2024/compatibility.proto"); + + assertThat(source) + .startsWith("edition = \"2024\";") + .contains("features.field_presence = EXPLICIT") + .contains("DOCUMENT_STATE_UNSPECIFIED = 0"); + } + + private static String resource(String path) { + try (InputStream stream = + GrpcEdition2024GateTest.class.getClassLoader().getResourceAsStream(path)) { + if (stream == null) { + throw new IllegalStateException("missing resource " + path); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/grpc-advanced/grpc-advanced-edition/src/test/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2026GuardTest.java b/src/grpc-advanced/grpc-advanced-edition/src/test/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2026GuardTest.java new file mode 100644 index 00000000..9423d691 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-edition/src/test/java/dev/caskeleton/grpc/advanced/edition/GrpcEdition2026GuardTest.java @@ -0,0 +1,93 @@ +package dev.caskeleton.grpc.advanced.edition; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcEdition2026GuardTest { + + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + + @Test + @DisplayName("the watch tracks four gates separately, because they land at different times") + void theWatchTracksFourGates() { + GrpcEdition2026WatchReport partial = + new GrpcEdition2026WatchReport( + GrpcEdition2026Status.RELEASED, + GrpcEdition2026Status.DRAFT, + GrpcEdition2026Status.UNKNOWN, + GrpcEdition2026Status.UNSUPPORTED, + NOW); + + assertThat(partial.outstanding()) + .hasSize(4) + .anySatisfy(item -> assertThat(item).contains("protoc support is DRAFT")) + .anySatisfy(item -> assertThat(item).contains("Buf support is UNKNOWN")) + .anySatisfy(item -> assertThat(item).contains("Java runtime support is UNSUPPORTED")); + assertThat(partial.readyToEvaluate()).isFalse(); + assertThat(GrpcEdition2026WatchReport.nothingKnown(NOW).outstanding()).hasSize(4); + } + + @Test + @DisplayName("a watch report is dated, so it cannot be told from a stale note") + void aWatchReportIsDated() { + assertThatThrownBy( + () -> + new GrpcEdition2026WatchReport( + GrpcEdition2026Status.SUPPORTED, + GrpcEdition2026Status.SUPPORTED, + GrpcEdition2026Status.SUPPORTED, + GrpcEdition2026Status.SUPPORTED, + null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("stale note"); + } + + @Test + @DisplayName("only SUPPORTED counts as usable") + void onlySupportedIsUsable() { + assertThat(GrpcEdition2026Status.SUPPORTED.usable()).isTrue(); + assertThat(GrpcEdition2026Status.RELEASED.usable()).isFalse(); + assertThat(GrpcEdition2026Status.DRAFT.usable()).isFalse(); + assertThat(GrpcEdition2026Status.UNKNOWN.usable()).isFalse(); + assertThat(GrpcEdition2026Status.UNSUPPORTED.usable()).isFalse(); + } + + @Test + @DisplayName("Edition 2026 is refused as a schema source even when every gate is satisfied") + void theGuardIsNotConditionalOnTheReport() { + GrpcEdition2026WatchReport allSupported = + new GrpcEdition2026WatchReport( + GrpcEdition2026Status.SUPPORTED, + GrpcEdition2026Status.SUPPORTED, + GrpcEdition2026Status.SUPPORTED, + GrpcEdition2026Status.SUPPORTED, + NOW); + + assertThat(allSupported.readyToEvaluate()).isTrue(); + assertThat(GrpcEdition2026Guard.allowedAsStableSource()).isFalse(); + assertThatThrownBy(() -> GrpcEdition2026Guard.requireNotUsedAsSource(allSupported)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("promotion decision rather than a schema change"); + } + + @Test + @DisplayName("a refusal names what is still outstanding, so it is actionable") + void aRefusalNamesWhatIsOutstanding() { + assertThatThrownBy( + () -> + GrpcEdition2026Guard.requireNotUsedAsSource( + GrpcEdition2026WatchReport.nothingKnown(NOW))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("outstanding:"); + } + + @Test + @DisplayName("a watch lane failure does not break the Stable build") + void theWatchLaneDoesNotBlockTheStableBuild() { + assertThat(GrpcEdition2026Guard.blocksStableBuild()).isFalse(); + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/build.gradle b/src/grpc-advanced/grpc-advanced-resilience/build.gradle new file mode 100644 index 00000000..12e8c98c --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/build.gradle @@ -0,0 +1,11 @@ +apply plugin: 'java-library' + +// Resilience and discovery capabilities that Stable refuses: read-only unary hedging, the custom +// name resolver SPI, the custom load balancer SPI, and the proxyless xDS experimental profile. +dependencies { + api project(':grpc:grpc-core-api') + api project(':grpc:grpc-policy') + api project(':grpc:grpc-client') + api project(':grpc:grpc-discovery') + api project(':grpc-advanced:grpc-advanced-bootstrap') +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/gradle.lockfile b/src/grpc-advanced/grpc-advanced-resilience/gradle.lockfile new file mode 100644 index 00000000..9369e831 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/gradle.lockfile @@ -0,0 +1,91 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty= diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcCustomResolver.java b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcCustomResolver.java new file mode 100644 index 00000000..d1562d5b --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcCustomResolver.java @@ -0,0 +1,75 @@ +package dev.caskeleton.grpc.advanced.discovery; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +/** + * A custom name resolver, with the safety rules applied on the way in. + * + *

Everything an update can do wrong is checked here rather than by the listener, because the + * listener is the channel and the channel will believe whatever it is told. Stale revisions and + * empty endpoint sets are dropped rather than propagated, and once closed the resolver accepts + * nothing at all. + */ +public final class GrpcCustomResolver implements AutoCloseable { + + private final String authority; + private final Consumer listener; + private final AtomicReference applied = new AtomicReference<>(); + private final AtomicBoolean closed = new AtomicBoolean(); + + /** Binds a resolver to the authority it resolves and the listener it feeds. */ + public GrpcCustomResolver(String authority, Consumer listener) { + if (authority == null || authority.isBlank()) { + throw new IllegalArgumentException("a resolver names the authority it resolves"); + } + if (listener == null) { + throw new IllegalArgumentException("a resolver needs a listener to deliver updates to"); + } + this.authority = authority; + this.listener = listener; + } + + /** + * Offers an update. + * + * @return the violations that stopped it, empty when it was applied + */ + public List offer(GrpcResolverUpdate update) { + if (closed.get()) { + return List.of( + "the resolver is closed; an update after close resurrects routing for a channel nobody " + + "is using"); + } + List violations = GrpcResolverSafetyPolicy.violations(update, applied.get()); + if (!violations.isEmpty()) { + return violations; + } + applied.set(update.snapshot()); + listener.accept(update); + return List.of(); + } + + /** The snapshot currently in force. */ + public Optional currentSnapshot() { + return Optional.ofNullable(applied.get()); + } + + /** The authority this resolver answers for. */ + public String authority() { + return authority; + } + + /** Whether the resolver has been closed. */ + public boolean closed() { + return closed.get(); + } + + @Override + public void close() { + closed.set(true); + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcEndpointCandidate.java b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcEndpointCandidate.java new file mode 100644 index 00000000..a7e49167 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcEndpointCandidate.java @@ -0,0 +1,36 @@ +package dev.caskeleton.grpc.advanced.discovery; + +/** + * One endpoint a picker may choose, described only by what routing is allowed to consider. + * + *

The field list is the allowlist. Health, connectivity, weight and ejection are properties of + * the endpoint; a tenant id or a request field is a property of the caller, and routing on those + * turns a load balancer into a router with an authorization decision buried in it. + */ +public record GrpcEndpointCandidate( + String address, boolean healthy, boolean connected, int weight, boolean ejected) { + + /** Requires an address and a sane weight. */ + public GrpcEndpointCandidate { + if (address == null || address.isBlank()) { + throw new IllegalArgumentException("an endpoint candidate needs an address"); + } + if (weight < 0) { + throw new IllegalArgumentException("a weight must not be negative"); + } + if (weight > 1000) { + throw new IllegalArgumentException( + "a weight above 1000 is a scale nobody can reason about against the others"); + } + } + + /** A healthy, connected endpoint at the default weight. */ + public static GrpcEndpointCandidate ready(String address) { + return new GrpcEndpointCandidate(address, true, true, 100, false); + } + + /** Whether this endpoint may receive a request. */ + public boolean selectable() { + return healthy && connected && !ejected && weight > 0; + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcEndpointSnapshot.java b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcEndpointSnapshot.java new file mode 100644 index 00000000..e5bb2bd0 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcEndpointSnapshot.java @@ -0,0 +1,46 @@ +package dev.caskeleton.grpc.advanced.discovery; + +import java.util.List; +import java.util.Set; + +/** + * One resolver update: a monotonic revision and the complete endpoint set at that revision. + * + *

Complete, not a delta. A delta protocol needs both sides to agree on what they last saw, and a + * resolver that reconnects to its discovery source has no way to establish that; a full set at each + * revision makes a missed update harmless. + * + *

The revision is what makes a late update safe to drop. Without it, an update that arrives out + * of order replaces newer endpoints with older ones, and the channel routes to instances that were + * removed. + */ +public record GrpcEndpointSnapshot(long revision, String authority, List endpoints) { + + /** Requires a positive revision, an authority and a non-empty endpoint set. */ + public GrpcEndpointSnapshot { + if (revision < 1) { + throw new IllegalArgumentException("resolver revisions are 1-based; got " + revision); + } + if (authority == null || authority.isBlank()) { + throw new IllegalArgumentException("a snapshot names the authority it resolves"); + } + if (endpoints == null || endpoints.isEmpty()) { + throw new IllegalArgumentException( + "an empty endpoint set is refused; a resolver that reports zero endpoints during its own " + + "outage would take the channel down with it"); + } + if (endpoints.stream().anyMatch(endpoint -> endpoint == null || endpoint.isBlank())) { + throw new IllegalArgumentException("every endpoint must be a non-blank address"); + } + endpoints = List.copyOf(endpoints); + if (Set.copyOf(endpoints).size() != endpoints.size()) { + throw new IllegalArgumentException( + "duplicate endpoints skew a round-robin picker towards whichever address is repeated"); + } + } + + /** Whether this snapshot supersedes {@code other}. */ + public boolean supersedes(GrpcEndpointSnapshot other) { + return other == null || (authority.equals(other.authority()) && revision > other.revision()); + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcLoadBalancerDecision.java b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcLoadBalancerDecision.java new file mode 100644 index 00000000..9a02e61e --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcLoadBalancerDecision.java @@ -0,0 +1,58 @@ +package dev.caskeleton.grpc.advanced.discovery; + +import java.util.Optional; + +/** + * Which endpoint a picker chose, or why it chose none. + * + *

{@link Verdict#DETERMINISTIC_FALLBACK} is separate from {@link Verdict#NO_ENDPOINT_AVAILABLE} + * because they say different things about the picker. The first means custom logic failed and the + * platform took over, which is a defect to fix; the second means there was genuinely nowhere to + * send the request, which is an outage. A picker that reports both the same way hides its own bugs + * inside the backend's. + */ +public record GrpcLoadBalancerDecision( + Verdict verdict, Optional chosen, String reason) { + + /** What the picker decided. */ + public enum Verdict { + /** The picker chose an endpoint. */ + PICKED, + /** The picker failed; the platform chose deterministically instead. */ + DETERMINISTIC_FALLBACK, + /** Nothing was selectable. */ + NO_ENDPOINT_AVAILABLE + } + + /** Requires an endpoint on the two verdicts that have one. */ + public GrpcLoadBalancerDecision { + if (verdict == null || chosen == null) { + throw new IllegalArgumentException("a picker decision has a verdict and the Optional"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("a picker decision explains itself"); + } + if (verdict == Verdict.NO_ENDPOINT_AVAILABLE && chosen.isPresent()) { + throw new IllegalArgumentException("a decision with no endpoint available carries none"); + } + if (verdict != Verdict.NO_ENDPOINT_AVAILABLE && chosen.isEmpty()) { + throw new IllegalArgumentException("a decision that picked carries the endpoint it picked"); + } + } + + /** The picker's own choice. */ + public static GrpcLoadBalancerDecision picked(GrpcEndpointCandidate endpoint, String reason) { + return new GrpcLoadBalancerDecision(Verdict.PICKED, Optional.of(endpoint), reason); + } + + /** The platform's fallback after a picker failure. */ + public static GrpcLoadBalancerDecision fallback(GrpcEndpointCandidate endpoint, String reason) { + return new GrpcLoadBalancerDecision( + Verdict.DETERMINISTIC_FALLBACK, Optional.of(endpoint), reason); + } + + /** Nothing was selectable. */ + public static GrpcLoadBalancerDecision none(String reason) { + return new GrpcLoadBalancerDecision(Verdict.NO_ENDPOINT_AVAILABLE, Optional.empty(), reason); + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcLoadBalancerPicker.java b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcLoadBalancerPicker.java new file mode 100644 index 00000000..cf929346 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcLoadBalancerPicker.java @@ -0,0 +1,29 @@ +package dev.caskeleton.grpc.advanced.discovery; + +import java.util.List; + +/** + * Chooses one endpoint from the candidates the resolver supplied. + * + *

The signature is the safety property. A picker receives a list of candidates and nothing else: + * no request, no metadata, no caller. It therefore cannot route on a tenant, and the rule "business + * data is not a routing input" is enforced by there being no business data to reach. + */ +@FunctionalInterface +public interface GrpcLoadBalancerPicker { + + /** + * Picks an endpoint. + * + * @param candidates the selectable endpoints, never empty + * @return the chosen endpoint, which must be one of {@code candidates} + */ + GrpcEndpointCandidate pick(List candidates); + + /** Round-robin, as the deterministic default and the fallback. */ + static GrpcLoadBalancerPicker roundRobin() { + java.util.concurrent.atomic.AtomicInteger cursor = + new java.util.concurrent.atomic.AtomicInteger(); + return candidates -> candidates.get(Math.floorMod(cursor.getAndIncrement(), candidates.size())); + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcLoadBalancerSafetyPolicy.java b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcLoadBalancerSafetyPolicy.java new file mode 100644 index 00000000..d8c2cca2 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcLoadBalancerSafetyPolicy.java @@ -0,0 +1,76 @@ +package dev.caskeleton.grpc.advanced.discovery; + +import java.util.List; + +/** + * Runs a custom picker and refuses to let it do something the resolver did not authorise. + * + *

Two rules. A picker may only return an endpoint the resolver supplied, because a picker that + * can invent an address can send a request anywhere; and a picker that throws produces a + * deterministic fallback rather than a failed call, because a picker bug should degrade the + * balancing rather than the availability. + * + *

Weighted and load-aware pickers are not refused here, but the plan requires evidence before + * they ship: {@link #requiresLoadEvidence} names which shapes those are. + */ +public final class GrpcLoadBalancerSafetyPolicy { + + private final GrpcLoadBalancerPicker picker; + private final GrpcLoadBalancerPicker fallback; + + /** Wraps a custom picker with the platform's deterministic fallback. */ + public GrpcLoadBalancerSafetyPolicy(GrpcLoadBalancerPicker picker) { + this(picker, GrpcLoadBalancerPicker.roundRobin()); + } + + /** Wraps a custom picker with an explicit fallback. */ + public GrpcLoadBalancerSafetyPolicy( + GrpcLoadBalancerPicker picker, GrpcLoadBalancerPicker fallback) { + if (picker == null || fallback == null) { + throw new IllegalArgumentException("a safety policy needs a picker and a fallback"); + } + this.picker = picker; + this.fallback = fallback; + } + + /** Picks an endpoint, or explains why none was chosen. */ + public GrpcLoadBalancerDecision pick(List candidates) { + if (candidates == null) { + throw new IllegalArgumentException("a candidate list is required"); + } + List selectable = + candidates.stream().filter(GrpcEndpointCandidate::selectable).toList(); + if (selectable.isEmpty()) { + return GrpcLoadBalancerDecision.none( + "no endpoint is healthy, connected, un-ejected and non-zero weight"); + } + GrpcEndpointCandidate chosen; + try { + chosen = picker.pick(selectable); + } catch (RuntimeException pickerFailure) { + return GrpcLoadBalancerDecision.fallback( + fallback.pick(selectable), + "the custom picker threw (" + + pickerFailure.getClass().getSimpleName() + + "); falling back deterministically rather than failing the call"); + } + if (chosen == null || !selectable.contains(chosen)) { + return GrpcLoadBalancerDecision.fallback( + fallback.pick(selectable), + "the custom picker returned an endpoint the resolver did not supply; a picker that can " + + "invent an address can send a request anywhere"); + } + return GrpcLoadBalancerDecision.picked(chosen, "chosen by the custom picker"); + } + + /** + * Whether a picker of this shape needs performance, fairness and failover evidence before it + * ships. + * + * @param loadAware whether the picker uses reported load or latency + * @param weighted whether the picker uses endpoint weights + */ + public static boolean requiresLoadEvidence(boolean loadAware, boolean weighted) { + return loadAware || weighted; + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcResolverSafetyPolicy.java b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcResolverSafetyPolicy.java new file mode 100644 index 00000000..0727a2fa --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcResolverSafetyPolicy.java @@ -0,0 +1,92 @@ +package dev.caskeleton.grpc.advanced.discovery; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * What a custom resolver is allowed to say, and what it may never carry. + * + *

A resolver runs inside the channel and speaks to something outside the deployment. Everything + * it can put into an update is therefore attacker-influenced in the worst case and + * operator-influenced in the ordinary one, so the safety rules are about limiting what an update + * can change: addresses and a validated service config, never a credential and never business + * metadata. + */ +public final class GrpcResolverSafetyPolicy { + + private static final Pattern AUTHORITY = + Pattern.compile("[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\\d{1,5})?"); + + private static final Pattern CREDENTIAL_SHAPED = + Pattern.compile("(?i).*(authorization|bearer|password|secret|token|api[_-]?key).*"); + + private GrpcResolverSafetyPolicy() {} + + /** + * Every problem with an update, given what was last accepted. + * + * @param lastAccepted the newest snapshot already applied, or null when none has been + * @return an empty list when the update is safe to apply + */ + public static List violations( + GrpcResolverUpdate update, GrpcEndpointSnapshot lastAccepted) { + if (update == null) { + throw new IllegalArgumentException("an update is required"); + } + List violations = new ArrayList<>(); + GrpcEndpointSnapshot snapshot = update.snapshot(); + + if (!AUTHORITY.matcher(snapshot.authority()).matches()) { + violations.add( + "authority '" + + snapshot.authority() + + "' is not a plain host or host:port; a resolver that can change the authority can " + + "change which certificate the channel accepts"); + } + if (!snapshot.supersedes(lastAccepted)) { + violations.add( + "revision " + + snapshot.revision() + + " does not supersede the applied revision " + + (lastAccepted == null ? "none" : lastAccepted.revision()) + + "; applying it would replace newer endpoints with older ones"); + } + update + .serviceConfigJson() + .ifPresent( + config -> { + if (CREDENTIAL_SHAPED.matcher(config).find()) { + violations.add( + "the pushed service config contains a credential-shaped field; a resolver " + + "supplies addresses and policy, never authentication material"); + } + }); + return List.copyOf(violations); + } + + /** + * Whether a closed resolver's update should be applied. + * + *

Always false. A resolver that keeps delivering after close is one whose discovery source has + * not noticed the channel is gone, and applying its updates resurrects routing for a channel + * nobody is using. + */ + public static boolean acceptAfterClose() { + return false; + } + + /** Whether a resolver may supply caller identity or business metadata. Always false. */ + public static boolean mayCarryBusinessMetadata() { + return false; + } + + /** The service config an update may contribute, once validated. */ + public static Optional acceptedServiceConfig( + GrpcResolverUpdate update, GrpcEndpointSnapshot lastAccepted) { + return violations(update, lastAccepted).isEmpty() + ? update.serviceConfigJson() + : Optional.empty(); + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcResolverUpdate.java b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcResolverUpdate.java new file mode 100644 index 00000000..1a3bfb2a --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/discovery/GrpcResolverUpdate.java @@ -0,0 +1,34 @@ +package dev.caskeleton.grpc.advanced.discovery; + +import java.util.Optional; + +/** + * A resolver's report: endpoints, and optionally the service config that goes with them. + * + *

The service config is optional and, when present, is validated as if a human had written it. A + * resolver that can push retry policy is a resolver that can turn on retries for a non-idempotent + * method from outside the codebase, and the fact that a control plane sent it is not evidence that + * anyone reviewed it. + */ +public record GrpcResolverUpdate( + GrpcEndpointSnapshot snapshot, Optional serviceConfigJson) { + + /** Requires a snapshot and the Optional. */ + public GrpcResolverUpdate { + if (snapshot == null || serviceConfigJson == null) { + throw new IllegalArgumentException("a resolver update carries a snapshot and the Optional"); + } + serviceConfigJson.ifPresent( + config -> { + if (config.isBlank()) { + throw new IllegalArgumentException( + "a present service config must not be blank; absent and empty are different states"); + } + }); + } + + /** An update with endpoints only. */ + public static GrpcResolverUpdate endpointsOnly(GrpcEndpointSnapshot snapshot) { + return new GrpcResolverUpdate(snapshot, Optional.empty()); + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/resilience/GrpcHedgingBudget.java b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/resilience/GrpcHedgingBudget.java new file mode 100644 index 00000000..ca391979 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/resilience/GrpcHedgingBudget.java @@ -0,0 +1,70 @@ +package dev.caskeleton.grpc.advanced.resilience; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * Caps duplicate attempts as a fraction of real traffic. + * + *

Necessary for the same reason a retry budget is, and more urgently. A retry happens after a + * failure; a hedge happens on a call that might have succeeded, so a fleet that hedges without a + * budget doubles its backend load in the steady state and doubles it again the moment latency + * rises. + */ +public final class GrpcHedgingBudget { + + private final long maxTokens; + private final long tokensPerHedge; + private final AtomicLong tokens; + + /** + * A budget that starts full. + * + * @param ratio hedges permitted per completed call, e.g. 0.1 for one hedge in ten + * @param maxTokens how much credit may accumulate, which bounds a burst after a quiet period + */ + public static GrpcHedgingBudget of(double ratio, long maxTokens) { + if (ratio <= 0.0d || ratio > 0.5d) { + throw new IllegalArgumentException( + "a hedging ratio above 0.5 means more than half of all calls are duplicated, which is a " + + "load decision rather than a latency one"); + } + if (maxTokens < 1) { + throw new IllegalArgumentException("a budget needs at least one token"); + } + return new GrpcHedgingBudget(maxTokens, Math.round(1.0d / ratio)); + } + + private GrpcHedgingBudget(long maxTokens, long tokensPerHedge) { + this.maxTokens = maxTokens; + this.tokensPerHedge = tokensPerHedge; + this.tokens = new AtomicLong(maxTokens); + } + + /** Takes the credit for one hedge, if there is any. */ + public boolean tryConsume() { + while (true) { + long observed = tokens.get(); + if (observed < tokensPerHedge) { + return false; + } + if (tokens.compareAndSet(observed, observed - tokensPerHedge)) { + return true; + } + } + } + + /** Records a completed call, which earns credit back. */ + public void recordCompletion() { + tokens.updateAndGet(observed -> Math.min(maxTokens, observed + 1L)); + } + + /** How much credit is left. */ + public long availableTokens() { + return tokens.get(); + } + + /** Whether another hedge could be afforded. */ + public boolean exhausted() { + return tokens.get() < tokensPerHedge; + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/resilience/GrpcHedgingEligibility.java b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/resilience/GrpcHedgingEligibility.java new file mode 100644 index 00000000..2e62c3ae --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/resilience/GrpcHedgingEligibility.java @@ -0,0 +1,72 @@ +package dev.caskeleton.grpc.advanced.resilience; + +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import dev.caskeleton.grpc.policy.RpcIdempotencyProfile; +import dev.caskeleton.grpc.resilience.GrpcRetryOwner; +import java.util.Optional; + +/** + * Whether a method may be hedged at all. + * + *

Read-only unary, and nothing else. A hedged mutation runs twice by design rather than by + * accident — both attempts are in flight, both may reach the server, and an idempotency key does + * not help because the second attempt is not a retry of a failure but a duplicate of a success in + * progress. A hedged stream is worse still: two streams deliver two prefixes. + */ +public final class GrpcHedgingEligibility { + + private GrpcHedgingEligibility() {} + + /** + * Why {@code policy} may not be hedged, or empty when it may. + * + * @return a refusal reason, or empty when hedging is permitted + */ + public static Optional refusalReason(GrpcMethodPolicy policy, GrpcRetryOwner retryOwner) { + if (policy == null || retryOwner == null) { + throw new IllegalArgumentException("eligibility needs a method policy and a retry owner"); + } + if (policy.rpcType() != RpcType.UNARY) { + return Optional.of( + "method '" + + policy.method().canonical() + + "' is " + + policy.rpcType() + + "; two hedged streams deliver two prefixes"); + } + if (policy.idempotency() != RpcIdempotencyProfile.READ_ONLY) { + return Optional.of( + "method '" + + policy.method().canonical() + + "' is " + + policy.idempotency() + + "; a hedged mutation runs twice by design, and an idempotency key does not help " + + "because the second attempt duplicates a success in progress rather than retrying a " + + "failure"); + } + if (!retryOwner.hedgingAllowed()) { + return Optional.of( + "retry owner is " + retryOwner + ", which does not permit in-process hedging"); + } + return Optional.empty(); + } + + /** Whether {@code policy} may be hedged. */ + public static boolean eligible(GrpcMethodPolicy policy, GrpcRetryOwner retryOwner) { + return refusalReason(policy, retryOwner).isEmpty(); + } + + /** + * Fails when a method may not be hedged. + * + * @throws IllegalStateException with the reason + */ + public static void require(GrpcMethodPolicy policy, GrpcRetryOwner retryOwner) { + refusalReason(policy, retryOwner) + .ifPresent( + reason -> { + throw new IllegalStateException("hedging refused: " + reason); + }); + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/resilience/GrpcHedgingPolicy.java b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/resilience/GrpcHedgingPolicy.java new file mode 100644 index 00000000..55b34e99 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/resilience/GrpcHedgingPolicy.java @@ -0,0 +1,51 @@ +package dev.caskeleton.grpc.advanced.resilience; + +import java.time.Duration; + +/** + * Duplicate in-flight attempts for a read, and the two bounds that keep them affordable. + * + *

Hedging trades backend load for tail latency: a second attempt goes out before the first has + * failed, so a slow replica stops mattering. The cost is that every hedged call may cost two, and + * it costs two precisely when the backend is already slow — which is why the attempt cap starts at + * 2 and the delay is required to be meaningfully above the median. + */ +public record GrpcHedgingPolicy(int maxAttempts, Duration hedgingDelay, Duration totalDeadline) { + + /** The initial cap. Raising it is a deliberate decision with load evidence behind it. */ + public static final int INITIAL_MAX_ATTEMPTS = 2; + + /** Refuses a policy whose duplicate load is unbounded or whose delay is meaningless. */ + public GrpcHedgingPolicy { + if (maxAttempts < 2) { + throw new IllegalArgumentException("hedging means at least two attempts; got " + maxAttempts); + } + if (maxAttempts > INITIAL_MAX_ATTEMPTS) { + throw new IllegalArgumentException( + "hedging is capped at " + + INITIAL_MAX_ATTEMPTS + + " attempts until load evidence justifies more; each extra attempt multiplies " + + "backend load exactly when the backend is already slow"); + } + if (hedgingDelay == null || hedgingDelay.isNegative()) { + throw new IllegalArgumentException("a hedging delay must be present and non-negative"); + } + if (hedgingDelay.isZero()) { + throw new IllegalArgumentException( + "a zero hedging delay sends every attempt at once, which doubles load for every call " + + "rather than for the slow ones"); + } + if (totalDeadline == null || totalDeadline.isZero() || totalDeadline.isNegative()) { + throw new IllegalArgumentException("hedging needs a total deadline to fit inside"); + } + if (hedgingDelay.compareTo(totalDeadline) >= 0) { + throw new IllegalArgumentException( + "the hedging delay is at or above the total deadline, so the second attempt never starts"); + } + } + + /** A policy that hedges once after {@code hedgingDelay}. */ + public static GrpcHedgingPolicy hedgeOnce(Duration hedgingDelay, Duration totalDeadline) { + return new GrpcHedgingPolicy(INITIAL_MAX_ATTEMPTS, hedgingDelay, totalDeadline); + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/resilience/GrpcHedgingResult.java b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/resilience/GrpcHedgingResult.java new file mode 100644 index 00000000..b09457b3 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/resilience/GrpcHedgingResult.java @@ -0,0 +1,49 @@ +package dev.caskeleton.grpc.advanced.resilience; + +import java.time.Duration; + +/** + * What a hedged call cost and what it saved. + * + *

Both numbers, because hedging is a trade and a dashboard that shows only the latency + * improvement makes it look free. {@code duplicateBackendCalls} is what the backend team sees, and + * {@code cancelledLoserAttempts} is how much of that work was thrown away. + */ +public record GrpcHedgingResult( + int attemptsIssued, + int winningAttempt, + int cancelledLoserAttempts, + int duplicateBackendCalls, + Duration observedLatency) { + + /** Requires coherent counts. */ + public GrpcHedgingResult { + if (attemptsIssued < 1) { + throw new IllegalArgumentException("a hedged call issues at least one attempt"); + } + if (winningAttempt < 1 || winningAttempt > attemptsIssued) { + throw new IllegalArgumentException("the winning attempt is one of the attempts issued"); + } + if (cancelledLoserAttempts < 0 || cancelledLoserAttempts > attemptsIssued - 1) { + throw new IllegalArgumentException( + "at most every attempt but the winner can be a cancelled loser"); + } + if (duplicateBackendCalls < 0 || duplicateBackendCalls > attemptsIssued - 1) { + throw new IllegalArgumentException( + "duplicate backend calls are the attempts beyond the first"); + } + if (observedLatency == null || observedLatency.isNegative()) { + throw new IllegalArgumentException("a hedged call records its latency"); + } + } + + /** A call that did not need to hedge. */ + public static GrpcHedgingResult firstAttemptWon(Duration latency) { + return new GrpcHedgingResult(1, 1, 0, 0, latency); + } + + /** Whether this call actually issued a duplicate. */ + public boolean hedged() { + return attemptsIssued > 1; + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/xds/GrpcXdsFailurePolicy.java b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/xds/GrpcXdsFailurePolicy.java new file mode 100644 index 00000000..212f0188 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/xds/GrpcXdsFailurePolicy.java @@ -0,0 +1,60 @@ +package dev.caskeleton.grpc.advanced.xds; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +/** + * What happens when the control plane goes away. + * + *

Last-known-good, with a bound. Serving forever from a stale snapshot means a decommissioned + * backend keeps receiving traffic indefinitely; failing immediately means a control-plane restart + * takes every client down with it. The bound is where the deployment decides which risk it prefers, + * and it has to be stated rather than inherited. + */ +public record GrpcXdsFailurePolicy( + Duration maxStaleness, boolean failFastOnMissingResource, Duration initialFetchTimeout) { + + /** Refuses a policy without a staleness bound. */ + public GrpcXdsFailurePolicy { + if (maxStaleness == null || maxStaleness.isZero() || maxStaleness.isNegative()) { + throw new IllegalArgumentException( + "last-known-good needs a staleness bound; without one a decommissioned backend keeps " + + "receiving traffic indefinitely"); + } + if (initialFetchTimeout == null + || initialFetchTimeout.isZero() + || initialFetchTimeout.isNegative()) { + throw new IllegalArgumentException( + "a client with no snapshot yet needs a bound on how long it waits before failing"); + } + } + + /** The default: fifteen minutes of last-known-good, fail fast on a resource that vanished. */ + public static GrpcXdsFailurePolicy standard() { + return new GrpcXdsFailurePolicy(Duration.ofMinutes(15), true, Duration.ofSeconds(15)); + } + + /** What a client should do given the newest snapshot it holds. */ + public Decision decide(Optional snapshot, Instant now) { + if (snapshot == null || now == null) { + throw new IllegalArgumentException("a decision needs the snapshot Optional and a moment"); + } + if (snapshot.isEmpty()) { + return Decision.NO_SNAPSHOT_YET; + } + return snapshot.get().ageAt(now).compareTo(maxStaleness) > 0 + ? Decision.STALE_BEYOND_BOUND + : Decision.SERVE_LAST_KNOWN_GOOD; + } + + /** What the client does about a control-plane outage. */ + public enum Decision { + /** Nothing has arrived yet; wait until the initial fetch timeout, then fail. */ + NO_SNAPSHOT_YET, + /** Keep routing on the snapshot in hand. */ + SERVE_LAST_KNOWN_GOOD, + /** The snapshot is older than the bound; stop trusting it. */ + STALE_BEYOND_BOUND + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/xds/GrpcXdsProfile.java b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/xds/GrpcXdsProfile.java new file mode 100644 index 00000000..629b8eb2 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/xds/GrpcXdsProfile.java @@ -0,0 +1,52 @@ +package dev.caskeleton.grpc.advanced.xds; + +import dev.caskeleton.grpc.resilience.GrpcRetryOwner; +import java.net.URI; + +/** + * A proxyless xDS deployment's configuration. + * + *

xDS moves routing, load balancing, retries and often mTLS out of the application and into a + * control plane. The consequence this profile encodes is that the application must stop configuring + * them: retry policy defined in both places is defined twice, and which one wins depends on + * resolution order rather than on anyone's decision. + */ +public record GrpcXdsProfile( + URI target, + String bootstrapReference, + String resourceNamespace, + GrpcRetryOwner retryOwner, + boolean controlPlaneMutualTls) { + + /** The only scheme an xDS target may use. */ + public static final String XDS_SCHEME = "xds"; + + /** Refuses a profile that would leave retries or routing owned in two places. */ + public GrpcXdsProfile { + if (target == null || !XDS_SCHEME.equals(target.getScheme())) { + throw new IllegalArgumentException( + "an xDS profile needs an 'xds:///' target; got '" + target + "'"); + } + if (bootstrapReference == null || bootstrapReference.isBlank()) { + throw new IllegalArgumentException( + "xDS needs a bootstrap reference; without one the client has no control plane to ask"); + } + if (resourceNamespace == null || resourceNamespace.isBlank()) { + throw new IllegalArgumentException( + "an xDS profile names its resource namespace; a client that subscribes to everything " + + "receives another team's routing"); + } + if (retryOwner != GrpcRetryOwner.SERVICE_MESH) { + throw new IllegalArgumentException( + "xDS routing means the control plane owns retries; a retry owner of " + + retryOwner + + " would define retry policy in two places, and which wins depends on resolution " + + "order rather than on a decision"); + } + if (!controlPlaneMutualTls) { + throw new IllegalArgumentException( + "the control-plane connection carries routing and often certificates; it is authenticated " + + "in both directions or it is a channel that can be impersonated"); + } + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/xds/GrpcXdsResourceSnapshot.java b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/xds/GrpcXdsResourceSnapshot.java new file mode 100644 index 00000000..59df6692 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/xds/GrpcXdsResourceSnapshot.java @@ -0,0 +1,40 @@ +package dev.caskeleton.grpc.advanced.xds; + +import java.time.Instant; +import java.util.List; + +/** + * What the control plane last said, and when. + * + *

The timestamp is what makes last-known-good usable. A snapshot with no age cannot answer + * whether the control plane has been silent for a minute or a day, and those are a transient blip + * and a serious incident. + */ +public record GrpcXdsResourceSnapshot( + String versionInfo, List resourceNames, Instant receivedAt) { + + /** Requires a version, at least one resource and a receipt time. */ + public GrpcXdsResourceSnapshot { + if (versionInfo == null || versionInfo.isBlank()) { + throw new IllegalArgumentException("an xDS snapshot carries the control plane's version"); + } + if (resourceNames == null || resourceNames.isEmpty()) { + throw new IllegalArgumentException( + "an empty resource set is not a snapshot; a control plane that returns nothing has not " + + "told the client its routing was removed"); + } + if (receivedAt == null) { + throw new IllegalArgumentException( + "a snapshot records when it arrived; without it, last-known-good cannot say how old it is"); + } + resourceNames = List.copyOf(resourceNames); + } + + /** How old this snapshot is at {@code now}. */ + public java.time.Duration ageAt(Instant now) { + if (now == null) { + throw new IllegalArgumentException("an age needs a moment"); + } + return java.time.Duration.between(receivedAt, now); + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/xds/GrpcXdsStartupGuard.java b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/xds/GrpcXdsStartupGuard.java new file mode 100644 index 00000000..eea0e5b6 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/main/java/dev/caskeleton/grpc/advanced/xds/GrpcXdsStartupGuard.java @@ -0,0 +1,100 @@ +package dev.caskeleton.grpc.advanced.xds; + +import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability; +import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedFeatureFlags; +import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedModuleGuard; +import java.util.ArrayList; +import java.util.List; + +/** + * Refuses to start an xDS channel that is not fully configured, and refuses to let xDS be described + * as Stable support. + * + *

The second refusal is the one worth having in code. xDS working in a deployment is not the + * same claim as the platform supporting it: it brings a control plane, its outage modes, its own + * security boundary and its own version skew, and the Stable support statement covers DNS and + * static targets. A support matrix that quietly widens is a support matrix nobody can rely on. + */ +public final class GrpcXdsStartupGuard { + + private GrpcXdsStartupGuard() {} + + /** + * Every reason an xDS channel may not start. + * + * @return an empty list when the profile and flags permit it + */ + public static List startupBlockers( + GrpcXdsProfile profile, GrpcAdvancedFeatureFlags flags, boolean applicationDefinesRetries) { + if (profile == null || flags == null) { + throw new IllegalArgumentException("startup validation needs a profile and the flags"); + } + List blockers = new ArrayList<>(); + if (!GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.XDS)) { + blockers.add( + "the xds capability is not available; it is " + + flags.gradeOf(GrpcAdvancedCapability.XDS) + + " and " + + (flags.flagSet(GrpcAdvancedCapability.XDS) + ? "production has not approved it" + : "its flag is not set")); + } + if (applicationDefinesRetries) { + blockers.add( + "the application also defines retry policy; with xDS the control plane owns it, and " + + "defining it in both places makes the winner depend on resolution order"); + } + return List.copyOf(blockers); + } + + /** + * Every disagreement between a deployment's profile and the bootstrap file its client will read. + * + *

Checked because the two are written by different people in different repositories, and the + * failure is silent: a client whose bootstrap names a namespace the deployment did not configure + * subscribes successfully and receives another team's routing. Nothing errors — the control plane + * answers, the resources parse, and traffic goes somewhere nobody chose. + * + *

Matched textually rather than with a JSON parser, deliberately. This leaf's test classpath + * is plain JUnit and AssertJ, and adding a JSON library to check three fields would put a parser + * on the runtime classpath of every deployment that enables xDS. + * + * @param bootstrapJson the bootstrap document's contents + * @return an empty list when the bootstrap and the profile agree + */ + public static List bootstrapMismatches(GrpcXdsProfile profile, String bootstrapJson) { + if (profile == null) { + throw new IllegalArgumentException("a profile is required"); + } + if (bootstrapJson == null || bootstrapJson.isBlank()) { + throw new IllegalArgumentException( + "the bootstrap document is required; a client with no bootstrap has no control plane to ask"); + } + List mismatches = new ArrayList<>(); + if (!bootstrapJson.contains("\"xds_servers\"")) { + mismatches.add("the bootstrap declares no xds_servers"); + } + if (!bootstrapJson.contains("\"channel_creds\"") || !bootstrapJson.contains("\"tls\"")) { + mismatches.add( + "the bootstrap's control-plane channel is not TLS; that connection carries routing and " + + "often certificates, so an unauthenticated one can be impersonated"); + } + if (!bootstrapJson.contains(profile.resourceNamespace())) { + mismatches.add( + "the bootstrap does not name the profile's resource namespace '" + + profile.resourceNamespace() + + "'; a client that subscribes outside its namespace receives another team's routing, " + + "and nothing about that fails"); + } + return List.copyOf(mismatches); + } + + /** + * Whether xDS may be advertised as part of Stable discovery support. + * + *

Always false. Stable support is DNS and static. + */ + public static boolean advertisableAsStableSupport() { + return false; + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/test/java/dev/caskeleton/grpc/advanced/resilience/GrpcHedgingEligibilityTest.java b/src/grpc-advanced/grpc-advanced-resilience/src/test/java/dev/caskeleton/grpc/advanced/resilience/GrpcHedgingEligibilityTest.java new file mode 100644 index 00000000..fe5713a5 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/test/java/dev/caskeleton/grpc/advanced/resilience/GrpcHedgingEligibilityTest.java @@ -0,0 +1,146 @@ +package dev.caskeleton.grpc.advanced.resilience; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import dev.caskeleton.grpc.policy.RpcIdempotencyProfile; +import dev.caskeleton.grpc.policy.WaitForReadyPolicy; +import dev.caskeleton.grpc.resilience.GrpcRetryOwner; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcHedgingEligibilityTest { + + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + private static final GrpcMethodName CREATE = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument"); + private static final GrpcMethodName WATCH = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/WatchDocuments"); + private static final GrpcDeadlineProfile TWO_SECONDS = + GrpcDeadlineProfile.of(Duration.ofSeconds(2)); + + @Test + @DisplayName("only a read-only unary method may be hedged") + void onlyReadOnlyUnaryMayBeHedged() { + assertThat( + GrpcHedgingEligibility.eligible( + GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS), GrpcRetryOwner.GRPC_PLATFORM)) + .isTrue(); + assertThat( + GrpcHedgingEligibility.refusalReason( + GrpcMethodPolicy.nonIdempotentUnary(CREATE, TWO_SECONDS), + GrpcRetryOwner.GRPC_PLATFORM)) + .hasValueSatisfying( + reason -> assertThat(reason).contains("duplicates a success in progress")); + } + + @Test + @DisplayName("a keyed mutation is still refused; an idempotency key does not make hedging safe") + void aKeyedMutationIsStillRefused() { + GrpcMethodPolicy keyed = + new GrpcMethodPolicy( + CREATE, + RpcType.UNARY, + RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED, + TWO_SECONDS, + WaitForReadyPolicy.DISABLED, + false, + 1024, + 1024); + + assertThat(GrpcHedgingEligibility.eligible(keyed, GrpcRetryOwner.GRPC_PLATFORM)).isFalse(); + } + + @Test + @DisplayName("a streaming method may not be hedged") + void streamingMayNotBeHedged() { + GrpcMethodPolicy streaming = + new GrpcMethodPolicy( + WATCH, + RpcType.SERVER_STREAMING, + RpcIdempotencyProfile.READ_ONLY, + TWO_SECONDS, + WaitForReadyPolicy.DISABLED, + false, + 1024, + 1024); + + assertThatThrownBy( + () -> GrpcHedgingEligibility.require(streaming, GrpcRetryOwner.GRPC_PLATFORM)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("two prefixes"); + } + + @Test + @DisplayName("a mesh-owned channel may not hedge in-process") + void aMeshOwnedChannelMayNotHedge() { + assertThat( + GrpcHedgingEligibility.eligible( + GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS), GrpcRetryOwner.SERVICE_MESH)) + .isFalse(); + assertThat( + GrpcHedgingEligibility.eligible( + GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS), GrpcRetryOwner.APPLICATION)) + .isFalse(); + } + + @Test + @DisplayName("hedging is capped at two attempts and needs a meaningful delay") + void hedgingIsCappedAndDelayed() { + assertThat( + GrpcHedgingPolicy.hedgeOnce(Duration.ofMillis(50), Duration.ofSeconds(2)).maxAttempts()) + .isEqualTo(2); + assertThatThrownBy(() -> new GrpcHedgingPolicy(3, Duration.ofMillis(50), Duration.ofSeconds(2))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("capped at 2"); + assertThatThrownBy(() -> new GrpcHedgingPolicy(2, Duration.ZERO, Duration.ofSeconds(2))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("doubles load for every call"); + assertThatThrownBy(() -> new GrpcHedgingPolicy(2, Duration.ofSeconds(3), Duration.ofSeconds(2))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("never starts"); + } + + @Test + @DisplayName("the hedging budget bounds duplicate load and refuses an absurd ratio") + void theHedgingBudgetBoundsDuplicateLoad() { + // ratio 0.5 costs two tokens per hedge, so a four-token budget affords two hedges and then + // needs two completions before it can afford another. + GrpcHedgingBudget budget = GrpcHedgingBudget.of(0.5d, 4L); + + assertThat(budget.tryConsume()).isTrue(); + assertThat(budget.tryConsume()).isTrue(); + assertThat(budget.tryConsume()).isFalse(); + assertThat(budget.availableTokens()).isZero(); + + budget.recordCompletion(); + assertThat(budget.exhausted()).isTrue(); + budget.recordCompletion(); + assertThat(budget.exhausted()).isFalse(); + + assertThatThrownBy(() -> GrpcHedgingBudget.of(0.9d, 10L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("load decision rather than a latency one"); + } + + @Test + @DisplayName("a hedged result records both the saving and the duplicate load") + void aHedgedResultRecordsBothSides() { + GrpcHedgingResult hedged = new GrpcHedgingResult(2, 2, 1, 1, Duration.ofMillis(40)); + + assertThat(hedged.hedged()).isTrue(); + assertThat(hedged.duplicateBackendCalls()).isEqualTo(1); + assertThat(hedged.cancelledLoserAttempts()).isEqualTo(1); + assertThat(GrpcHedgingResult.firstAttemptWon(Duration.ofMillis(10)).hedged()).isFalse(); + assertThatThrownBy(() -> new GrpcHedgingResult(2, 3, 0, 0, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new GrpcHedgingResult(2, 1, 2, 0, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/test/java/dev/caskeleton/grpc/advanced/resilience/GrpcLoadBalancerSafetyPolicyTest.java b/src/grpc-advanced/grpc-advanced-resilience/src/test/java/dev/caskeleton/grpc/advanced/resilience/GrpcLoadBalancerSafetyPolicyTest.java new file mode 100644 index 00000000..cf8c0349 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/test/java/dev/caskeleton/grpc/advanced/resilience/GrpcLoadBalancerSafetyPolicyTest.java @@ -0,0 +1,124 @@ +package dev.caskeleton.grpc.advanced.resilience; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.advanced.discovery.GrpcEndpointCandidate; +import dev.caskeleton.grpc.advanced.discovery.GrpcLoadBalancerDecision; +import dev.caskeleton.grpc.advanced.discovery.GrpcLoadBalancerPicker; +import dev.caskeleton.grpc.advanced.discovery.GrpcLoadBalancerSafetyPolicy; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcLoadBalancerSafetyPolicyTest { + + @Test + @DisplayName("a picker may only choose an endpoint the resolver supplied") + void aPickerMayNotInventAnEndpoint() { + GrpcLoadBalancerSafetyPolicy policy = + new GrpcLoadBalancerSafetyPolicy(candidates -> GrpcEndpointCandidate.ready("10.9.9.9")); + + GrpcLoadBalancerDecision decision = + policy.pick(List.of(GrpcEndpointCandidate.ready("10.0.0.1"))); + + assertThat(decision.verdict()) + .isEqualTo(GrpcLoadBalancerDecision.Verdict.DETERMINISTIC_FALLBACK); + assertThat(decision.reason()).contains("can send a request anywhere"); + assertThat(decision.chosen()) + .hasValueSatisfying(endpoint -> assertThat(endpoint.address()).isEqualTo("10.0.0.1")); + } + + @Test + @DisplayName("a picker that throws degrades balancing, not availability") + void aThrowingPickerFallsBack() { + GrpcLoadBalancerSafetyPolicy policy = + new GrpcLoadBalancerSafetyPolicy( + candidates -> { + throw new IllegalStateException("picker bug"); + }); + + GrpcLoadBalancerDecision decision = + policy.pick(List.of(GrpcEndpointCandidate.ready("10.0.0.1"))); + + assertThat(decision.verdict()) + .isEqualTo(GrpcLoadBalancerDecision.Verdict.DETERMINISTIC_FALLBACK); + assertThat(decision.reason()).contains("IllegalStateException"); + } + + @Test + @DisplayName("a picker returning null falls back rather than failing the call") + void aNullPickFallsBack() { + GrpcLoadBalancerSafetyPolicy policy = new GrpcLoadBalancerSafetyPolicy(candidates -> null); + + assertThat(policy.pick(List.of(GrpcEndpointCandidate.ready("10.0.0.1"))).verdict()) + .isEqualTo(GrpcLoadBalancerDecision.Verdict.DETERMINISTIC_FALLBACK); + } + + @Test + @DisplayName("no selectable endpoint is a different verdict from a picker failure") + void noEndpointDiffersFromAPickerFailure() { + GrpcLoadBalancerSafetyPolicy policy = + new GrpcLoadBalancerSafetyPolicy(GrpcLoadBalancerPicker.roundRobin()); + + assertThat( + policy + .pick(List.of(new GrpcEndpointCandidate("10.0.0.1", false, true, 100, false))) + .verdict()) + .isEqualTo(GrpcLoadBalancerDecision.Verdict.NO_ENDPOINT_AVAILABLE); + assertThat( + policy + .pick(List.of(new GrpcEndpointCandidate("10.0.0.1", true, true, 100, true))) + .verdict()) + .isEqualTo(GrpcLoadBalancerDecision.Verdict.NO_ENDPOINT_AVAILABLE); + assertThat( + policy + .pick(List.of(new GrpcEndpointCandidate("10.0.0.1", true, true, 0, false))) + .verdict()) + .isEqualTo(GrpcLoadBalancerDecision.Verdict.NO_ENDPOINT_AVAILABLE); + } + + @Test + @DisplayName("a healthy endpoint is picked and reported as the picker's own choice") + void aHealthyEndpointIsPicked() { + GrpcLoadBalancerSafetyPolicy policy = + new GrpcLoadBalancerSafetyPolicy(GrpcLoadBalancerPicker.roundRobin()); + + assertThat( + policy + .pick( + List.of( + GrpcEndpointCandidate.ready("10.0.0.1"), + GrpcEndpointCandidate.ready("10.0.0.2"))) + .verdict()) + .isEqualTo(GrpcLoadBalancerDecision.Verdict.PICKED); + } + + @Test + @DisplayName("a picker sees endpoints only, never a request or a caller") + void aPickerSeesEndpointsOnly() { + assertThat(GrpcLoadBalancerPicker.class.getMethods()) + .filteredOn(method -> "pick".equals(method.getName())) + .singleElement() + .satisfies(method -> assertThat(method.getParameterCount()).isEqualTo(1)); + } + + @Test + @DisplayName("a candidate carries only routing-relevant state, bounded") + void aCandidateCarriesOnlyRoutingState() { + assertThat(GrpcEndpointCandidate.ready("10.0.0.1").selectable()).isTrue(); + assertThatThrownBy(() -> new GrpcEndpointCandidate("10.0.0.1", true, true, 5000, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("scale nobody can reason about"); + assertThatThrownBy(() -> new GrpcEndpointCandidate(" ", true, true, 100, false)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a load-aware or weighted picker requires evidence before it ships") + void loadAwarePickersRequireEvidence() { + assertThat(GrpcLoadBalancerSafetyPolicy.requiresLoadEvidence(true, false)).isTrue(); + assertThat(GrpcLoadBalancerSafetyPolicy.requiresLoadEvidence(false, true)).isTrue(); + assertThat(GrpcLoadBalancerSafetyPolicy.requiresLoadEvidence(false, false)).isFalse(); + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/test/java/dev/caskeleton/grpc/advanced/resilience/GrpcResolverSafetyPolicyTest.java b/src/grpc-advanced/grpc-advanced-resilience/src/test/java/dev/caskeleton/grpc/advanced/resilience/GrpcResolverSafetyPolicyTest.java new file mode 100644 index 00000000..0821a872 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/test/java/dev/caskeleton/grpc/advanced/resilience/GrpcResolverSafetyPolicyTest.java @@ -0,0 +1,119 @@ +package dev.caskeleton.grpc.advanced.resilience; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.advanced.discovery.GrpcCustomResolver; +import dev.caskeleton.grpc.advanced.discovery.GrpcEndpointSnapshot; +import dev.caskeleton.grpc.advanced.discovery.GrpcResolverSafetyPolicy; +import dev.caskeleton.grpc.advanced.discovery.GrpcResolverUpdate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcResolverSafetyPolicyTest { + + @Test + @DisplayName("an empty or duplicate endpoint set is refused") + void anEmptyOrDuplicateEndpointSetIsRefused() { + assertThatThrownBy(() -> new GrpcEndpointSnapshot(1L, "documents", List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("take the channel down with it"); + assertThatThrownBy( + () -> new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1", "10.0.0.1"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("skew a round-robin picker"); + assertThatThrownBy(() -> new GrpcEndpointSnapshot(0L, "documents", List.of("10.0.0.1"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a stale revision is dropped rather than applied") + void aStaleRevisionIsDropped() { + List delivered = new ArrayList<>(); + try (GrpcCustomResolver resolver = new GrpcCustomResolver("documents", delivered::add)) { + assertThat( + resolver.offer( + GrpcResolverUpdate.endpointsOnly( + new GrpcEndpointSnapshot(2L, "documents", List.of("10.0.0.1"))))) + .isEmpty(); + assertThat( + resolver.offer( + GrpcResolverUpdate.endpointsOnly( + new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.9"))))) + .anySatisfy(violation -> assertThat(violation).contains("older ones")); + assertThat(delivered).hasSize(1); + assertThat(resolver.currentSnapshot()) + .hasValueSatisfying(snapshot -> assertThat(snapshot.revision()).isEqualTo(2L)); + assertThat(resolver.authority()).isEqualTo("documents"); + } + } + + @Test + @DisplayName("a resolver accepts nothing after close") + void aClosedResolverAcceptsNothing() { + List delivered = new ArrayList<>(); + GrpcCustomResolver resolver = new GrpcCustomResolver("documents", delivered::add); + resolver.close(); + + assertThat(resolver.closed()).isTrue(); + assertThat( + resolver.offer( + GrpcResolverUpdate.endpointsOnly( + new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1"))))) + .anySatisfy(violation -> assertThat(violation).contains("resolver is closed")); + assertThat(delivered).isEmpty(); + assertThat(GrpcResolverSafetyPolicy.acceptAfterClose()).isFalse(); + } + + @Test + @DisplayName("a resolver may not push credentials or business metadata") + void aResolverMayNotPushCredentials() { + GrpcResolverUpdate withCredential = + new GrpcResolverUpdate( + new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1")), + Optional.of("{\"authorization\":\"Bearer abc\"}")); + + assertThat(GrpcResolverSafetyPolicy.violations(withCredential, null)) + .anySatisfy(violation -> assertThat(violation).contains("never authentication material")); + assertThat(GrpcResolverSafetyPolicy.mayCarryBusinessMetadata()).isFalse(); + assertThat(GrpcResolverSafetyPolicy.acceptedServiceConfig(withCredential, null)).isEmpty(); + } + + @Test + @DisplayName("a clean service config survives validation") + void aCleanServiceConfigSurvives() { + GrpcResolverUpdate clean = + new GrpcResolverUpdate( + new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1")), + Optional.of("{\"loadBalancingConfig\":[{\"round_robin\":{}}]}")); + + assertThat(GrpcResolverSafetyPolicy.violations(clean, null)).isEmpty(); + assertThat(GrpcResolverSafetyPolicy.acceptedServiceConfig(clean, null)).isPresent(); + } + + @Test + @DisplayName("an invalid authority is refused, because it decides which certificate is accepted") + void anInvalidAuthorityIsRefused() { + assertThat( + GrpcResolverSafetyPolicy.violations( + GrpcResolverUpdate.endpointsOnly( + new GrpcEndpointSnapshot(1L, "Documents Service", List.of("10.0.0.1"))), + null)) + .anySatisfy(violation -> assertThat(violation).contains("which certificate")); + } + + @Test + @DisplayName("a present service config may not be blank") + void aPresentServiceConfigMayNotBeBlank() { + assertThatThrownBy( + () -> + new GrpcResolverUpdate( + new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1")), + Optional.of(" "))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("different states"); + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/test/java/dev/caskeleton/grpc/advanced/resilience/GrpcXdsStartupGuardTest.java b/src/grpc-advanced/grpc-advanced-resilience/src/test/java/dev/caskeleton/grpc/advanced/resilience/GrpcXdsStartupGuardTest.java new file mode 100644 index 00000000..3e039001 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/test/java/dev/caskeleton/grpc/advanced/resilience/GrpcXdsStartupGuardTest.java @@ -0,0 +1,188 @@ +package dev.caskeleton.grpc.advanced.resilience; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability; +import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedFeatureFlags; +import dev.caskeleton.grpc.advanced.xds.GrpcXdsFailurePolicy; +import dev.caskeleton.grpc.advanced.xds.GrpcXdsProfile; +import dev.caskeleton.grpc.advanced.xds.GrpcXdsResourceSnapshot; +import dev.caskeleton.grpc.advanced.xds.GrpcXdsStartupGuard; +import dev.caskeleton.grpc.resilience.GrpcRetryOwner; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcXdsStartupGuardTest { + + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + + private static GrpcXdsProfile profile(String namespace) { + return new GrpcXdsProfile( + URI.create("xds:///documents"), + "classpath:/xds/bootstrap.json", + namespace, + GrpcRetryOwner.SERVICE_MESH, + true); + } + + @Test + @DisplayName("an xDS profile requires an xds target, a namespace, mesh retries and mTLS") + void anXdsProfileRequiresItsFourConditions() { + assertThat(profile("hyeonworks/documents").target().getScheme()).isEqualTo("xds"); + assertThatThrownBy( + () -> + new GrpcXdsProfile( + URI.create("dns:///documents"), + "file:/etc/grpc/bootstrap.json", + "ns", + GrpcRetryOwner.SERVICE_MESH, + true)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new GrpcXdsProfile( + URI.create("xds:///documents"), " ", "ns", GrpcRetryOwner.SERVICE_MESH, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("no control plane to ask"); + assertThatThrownBy( + () -> + new GrpcXdsProfile( + URI.create("xds:///documents"), + "file:/etc/grpc/bootstrap.json", + "ns", + GrpcRetryOwner.APPLICATION, + true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("two places"); + assertThatThrownBy( + () -> + new GrpcXdsProfile( + URI.create("xds:///documents"), + "file:/etc/grpc/bootstrap.json", + "ns", + GrpcRetryOwner.SERVICE_MESH, + false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("impersonated"); + } + + @Test + @DisplayName("xDS needs its capability approved and refuses duplicate retry ownership") + void startupRequiresApprovalAndSingleRetryOwner() { + GrpcAdvancedFeatureFlags unapproved = + GrpcAdvancedFeatureFlags.forProduction(Set.of()).enable(GrpcAdvancedCapability.XDS); + GrpcAdvancedFeatureFlags approved = + GrpcAdvancedFeatureFlags.forProduction(Set.of(GrpcAdvancedCapability.XDS)) + .enable(GrpcAdvancedCapability.XDS); + + assertThat( + GrpcXdsStartupGuard.startupBlockers(profile("hyeonworks/documents"), unapproved, false)) + .anySatisfy(blocker -> assertThat(blocker).contains("production has not approved it")); + assertThat( + GrpcXdsStartupGuard.startupBlockers(profile("hyeonworks/documents"), approved, false)) + .isEmpty(); + assertThat(GrpcXdsStartupGuard.startupBlockers(profile("hyeonworks/documents"), approved, true)) + .anySatisfy(blocker -> assertThat(blocker).contains("resolution order")); + } + + @Test + @DisplayName("an unflagged capability is reported as unflagged rather than unapproved") + void anUnflaggedCapabilityIsReportedAsSuch() { + assertThat( + GrpcXdsStartupGuard.startupBlockers( + profile("hyeonworks/documents"), + GrpcAdvancedFeatureFlags.forProduction(Set.of()), + false)) + .anySatisfy(blocker -> assertThat(blocker).contains("its flag is not set")); + } + + @Test + @DisplayName("the committed bootstrap fixture agrees with the profile it is meant to serve") + void theBootstrapFixtureAgreesWithItsProfile() { + assertThat( + GrpcXdsStartupGuard.bootstrapMismatches( + profile("hyeonworks/documents"), resource("xds/bootstrap.json"))) + .isEmpty(); + } + + @Test + @DisplayName("a bootstrap naming another namespace is reported, since nothing else would fail") + void aNamespaceMismatchIsReported() { + assertThat( + GrpcXdsStartupGuard.bootstrapMismatches( + profile("hyeonworks/billing"), resource("xds/bootstrap.json"))) + .anySatisfy(mismatch -> assertThat(mismatch).contains("another team's routing")); + } + + @Test + @DisplayName("a bootstrap with an unauthenticated control-plane channel is reported") + void anUnauthenticatedControlPlaneIsReported() { + String insecure = + resource("xds/bootstrap.json") + .replace("{ \"type\": \"tls\" }", "{ \"type\": \"insecure\" }"); + + assertThat(GrpcXdsStartupGuard.bootstrapMismatches(profile("hyeonworks/documents"), insecure)) + .anySatisfy(mismatch -> assertThat(mismatch).contains("can be impersonated")); + } + + @Test + @DisplayName("a missing bootstrap is refused rather than treated as an empty one") + void aMissingBootstrapIsRefused() { + assertThatThrownBy( + () -> GrpcXdsStartupGuard.bootstrapMismatches(profile("hyeonworks/documents"), " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("no control plane to ask"); + } + + @Test + @DisplayName("last-known-good is bounded, and an empty resource set is not a snapshot") + void lastKnownGoodIsBounded() { + GrpcXdsFailurePolicy policy = GrpcXdsFailurePolicy.standard(); + GrpcXdsResourceSnapshot snapshot = + new GrpcXdsResourceSnapshot("v7", List.of("documents-cluster"), NOW); + + assertThat(policy.decide(Optional.of(snapshot), NOW.plusSeconds(60))) + .isEqualTo(GrpcXdsFailurePolicy.Decision.SERVE_LAST_KNOWN_GOOD); + assertThat(policy.decide(Optional.of(snapshot), NOW.plusSeconds(1000))) + .isEqualTo(GrpcXdsFailurePolicy.Decision.STALE_BEYOND_BOUND); + assertThat(policy.decide(Optional.empty(), NOW)) + .isEqualTo(GrpcXdsFailurePolicy.Decision.NO_SNAPSHOT_YET); + assertThat(snapshot.ageAt(NOW.plusSeconds(60))).isEqualTo(java.time.Duration.ofSeconds(60)); + assertThatThrownBy(() -> new GrpcXdsResourceSnapshot("v7", List.of(), NOW)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new GrpcXdsFailurePolicy( + java.time.Duration.ZERO, true, java.time.Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("receiving traffic indefinitely"); + } + + @Test + @DisplayName("xDS is not part of the Stable discovery support statement") + void xdsIsNotStableSupport() { + assertThat(GrpcXdsStartupGuard.advertisableAsStableSupport()).isFalse(); + } + + private static String resource(String path) { + try (InputStream stream = + GrpcXdsStartupGuardTest.class.getClassLoader().getResourceAsStream(path)) { + if (stream == null) { + throw new IllegalStateException("missing test resource " + path); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/grpc-advanced/grpc-advanced-resilience/src/test/resources/xds/bootstrap.json b/src/grpc-advanced/grpc-advanced-resilience/src/test/resources/xds/bootstrap.json new file mode 100644 index 00000000..d118a076 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-resilience/src/test/resources/xds/bootstrap.json @@ -0,0 +1,40 @@ +{ + "_comment": [ + "An xDS bootstrap fixture. The client reads this file to learn where its control plane is and", + "who it claims to be; GrpcXdsProfile validates the deployment settings that must agree with it.", + "The two fields the profile actually checks against are server_uri (the control plane must be", + "reached over authenticated mTLS) and the node id's namespace (a client that subscribes outside", + "its namespace receives another team's routing)." + ], + "xds_servers": [ + { + "server_uri": "xds-control-plane.hyeonworks.internal:15010", + "channel_creds": [ + { "type": "tls" } + ], + "server_features": ["xds_v3"] + } + ], + "node": { + "id": "hyeonworks/documents/documents-7f9c4", + "cluster": "documents", + "metadata": { + "NAMESPACE": "hyeonworks/documents" + }, + "locality": { + "region": "ap-northeast-2", + "zone": "ap-northeast-2a" + } + }, + "authorities": { + "hyeonworks.internal": { + "xds_servers": [ + { + "server_uri": "xds-control-plane.hyeonworks.internal:15010", + "channel_creds": [{ "type": "tls" }], + "server_features": ["xds_v3"] + } + ] + } + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/build.gradle b/src/grpc-advanced/grpc-advanced-streaming/build.gradle new file mode 100644 index 00000000..41804270 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/build.gradle @@ -0,0 +1,10 @@ +apply plugin: 'java-library' + +// The streaming shapes the Stable plan deliberately excludes: client streaming sessions with +// dedup/checkpoint/resume, bidirectional sessions with independent per-direction sequences, and the +// manual flow-control approval API. +dependencies { + api project(':grpc:grpc-core-api') + api project(':grpc:grpc-policy') + api project(':grpc-advanced:grpc-advanced-bootstrap') +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/gradle.lockfile b/src/grpc-advanced/grpc-advanced-streaming/gradle.lockfile new file mode 100644 index 00000000..9369e831 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/gradle.lockfile @@ -0,0 +1,91 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty= diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcBidiDirectionState.java b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcBidiDirectionState.java new file mode 100644 index 00000000..26a9afa6 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcBidiDirectionState.java @@ -0,0 +1,61 @@ +package dev.caskeleton.grpc.advanced.streaming; + +/** + * One direction of a bidirectional stream, tracked on its own. + * + *

Independently, because the two directions genuinely end at different times: a client that has + * finished sending is still receiving, and a server that has finished sending is still reading. A + * single state for both cannot express either, and the usual symptom is a stream closed while one + * side still had messages to deliver. + */ +public record GrpcBidiDirectionState(long lastSequence, boolean halfClosed, boolean cancelled) { + + /** Requires a non-negative position. */ + public GrpcBidiDirectionState { + if (lastSequence < 0) { + throw new IllegalArgumentException("a direction's sequence is at or after zero"); + } + if (halfClosed && cancelled) { + throw new IllegalArgumentException( + "a direction that was cancelled did not half-close; the two are different endings and a " + + "client acts on them differently"); + } + } + + /** A direction that has sent nothing yet. */ + public static GrpcBidiDirectionState open() { + return new GrpcBidiDirectionState(0L, false, false); + } + + /** + * Records a message at the next sequence. + * + * @throws IllegalStateException when the direction has already ended + */ + public GrpcBidiDirectionState advanced() { + if (halfClosed || cancelled) { + throw new IllegalStateException( + "this direction has already ended; a message after half-close is a protocol error, not a " + + "late arrival"); + } + return new GrpcBidiDirectionState(lastSequence + 1, false, false); + } + + /** Records that this direction will send nothing more. */ + public GrpcBidiDirectionState halfClose() { + if (cancelled) { + throw new IllegalStateException("a cancelled direction cannot half-close"); + } + return new GrpcBidiDirectionState(lastSequence, true, false); + } + + /** Records that this direction was cancelled. */ + public GrpcBidiDirectionState cancel() { + return new GrpcBidiDirectionState(lastSequence, false, true); + } + + /** Whether this direction has finished. */ + public boolean ended() { + return halfClosed || cancelled; + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcBidiResumeState.java b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcBidiResumeState.java new file mode 100644 index 00000000..68cfbd46 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcBidiResumeState.java @@ -0,0 +1,42 @@ +package dev.caskeleton.grpc.advanced.streaming; + +/** + * What a bidirectional resume has to carry: both applied positions, and the generation they belong + * to. + * + *

Both, because a resume that restores one direction and restarts the other produces exactly the + * failure the dual sequence exists to prevent. The generation is checked first: a mismatch means + * the session was restarted rather than resumed, and continuing from either position would be + * meaningless. + */ +public record GrpcBidiResumeState( + GrpcClientStreamSessionId sessionId, long clientAppliedSequence, long serverAppliedSequence) { + + /** Requires a session and two non-negative positions. */ + public GrpcBidiResumeState { + if (sessionId == null) { + throw new IllegalArgumentException("a bidi resume state belongs to a session"); + } + if (clientAppliedSequence < 0 || serverAppliedSequence < 0) { + throw new IllegalArgumentException("applied sequences are at or after zero"); + } + } + + /** + * Whether {@code presented} can continue this session. + * + *

A generation mismatch requires a full restart. The session was re-established rather than + * resumed, so neither side's position refers to the same stream of messages. + */ + public boolean resumableFrom(GrpcClientStreamSessionId presented) { + return presented != null + && sessionId.sameSessionAs(presented) + && presented.generation() == sessionId.generation(); + } + + /** The state after a resume, at the next generation. */ + public GrpcBidiResumeState resumed() { + return new GrpcBidiResumeState( + sessionId.resumed(), clientAppliedSequence, serverAppliedSequence); + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcBidiSequenceTracker.java b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcBidiSequenceTracker.java new file mode 100644 index 00000000..b0483dd4 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcBidiSequenceTracker.java @@ -0,0 +1,62 @@ +package dev.caskeleton.grpc.advanced.streaming; + +/** + * Two sequences, one per direction, never merged. + * + *

Merging them is the mistake this class exists to prevent, and it is tempting because one + * counter looks simpler. It is not equivalent: the client's message 5 and the server's message 5 + * are unrelated events, and a shared counter makes a resume token from one side meaningless to the + * other — so a reconnect either skips or replays, depending on which side moved faster. + */ +public final class GrpcBidiSequenceTracker { + + private GrpcBidiDirectionState clientToServer = GrpcBidiDirectionState.open(); + private GrpcBidiDirectionState serverToClient = GrpcBidiDirectionState.open(); + + /** Records a client-to-server message and returns its sequence. */ + public synchronized long nextClientSequence() { + clientToServer = clientToServer.advanced(); + return clientToServer.lastSequence(); + } + + /** Records a server-to-client message and returns its sequence. */ + public synchronized long nextServerSequence() { + serverToClient = serverToClient.advanced(); + return serverToClient.lastSequence(); + } + + /** The client-to-server direction. */ + public synchronized GrpcBidiDirectionState clientToServer() { + return clientToServer; + } + + /** The server-to-client direction. */ + public synchronized GrpcBidiDirectionState serverToClient() { + return serverToClient; + } + + /** Records that the client will send nothing more. */ + public synchronized void halfCloseClient() { + clientToServer = clientToServer.halfClose(); + } + + /** Records that the server will send nothing more. */ + public synchronized void halfCloseServer() { + serverToClient = serverToClient.halfClose(); + } + + /** Cancels one direction without touching the other. */ + public synchronized void cancelClient() { + clientToServer = clientToServer.cancel(); + } + + /** Cancels the server direction without touching the client's. */ + public synchronized void cancelServer() { + serverToClient = serverToClient.cancel(); + } + + /** Whether both directions have ended. */ + public synchronized boolean bothEnded() { + return clientToServer.ended() && serverToClient.ended(); + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcBidiSession.java b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcBidiSession.java new file mode 100644 index 00000000..bc45b52d --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcBidiSession.java @@ -0,0 +1,77 @@ +package dev.caskeleton.grpc.advanced.streaming; + +import dev.caskeleton.grpc.streaming.GrpcFlowControlPolicy; +import java.util.Optional; + +/** + * A bidirectional session: two directions, two queues, two single writers. + * + *

Two of everything, because the two directions have independent producers and independent + * consumers. Sharing a queue between them means the slower direction throttles the faster one; and + * sharing a writer reintroduces the concurrent-{@code onNext} problem the Stable serialized writer + * exists to solve, this time with the two sides of the same call racing each other. + */ +public final class GrpcBidiSession { + + private final GrpcClientStreamSessionId sessionId; + private final GrpcBidiSequenceTracker sequences = new GrpcBidiSequenceTracker(); + private final GrpcFlowControlPolicy inboundFlowControl; + private final GrpcFlowControlPolicy outboundFlowControl; + + /** Opens a session with an independent flow-control policy per direction. */ + public GrpcBidiSession( + GrpcClientStreamSessionId sessionId, + GrpcFlowControlPolicy inboundFlowControl, + GrpcFlowControlPolicy outboundFlowControl) { + if (sessionId == null || inboundFlowControl == null || outboundFlowControl == null) { + throw new IllegalArgumentException( + "a bidi session needs an id and a flow-control policy for each direction"); + } + this.sessionId = sessionId; + this.inboundFlowControl = inboundFlowControl; + this.outboundFlowControl = outboundFlowControl; + } + + /** The session's identity. */ + public GrpcClientStreamSessionId sessionId() { + return sessionId; + } + + /** The sequence tracker. */ + public GrpcBidiSequenceTracker sequences() { + return sequences; + } + + /** The inbound direction's bounds. */ + public GrpcFlowControlPolicy inboundFlowControl() { + return inboundFlowControl; + } + + /** The outbound direction's bounds. */ + public GrpcFlowControlPolicy outboundFlowControl() { + return outboundFlowControl; + } + + /** The resume state as it stands. */ + public GrpcBidiResumeState resumeState() { + return new GrpcBidiResumeState( + sessionId, + sequences.clientToServer().lastSequence(), + sequences.serverToClient().lastSequence()); + } + + /** + * Whether {@code presented} may continue this session, and from where. + * + * @return empty when the generation does not match, which requires a full session restart + */ + public Optional resumeFrom(GrpcClientStreamSessionId presented) { + GrpcBidiResumeState state = resumeState(); + return state.resumableFrom(presented) ? Optional.of(state) : Optional.empty(); + } + + /** Whether the session is finished in both directions. */ + public boolean complete() { + return sequences.bothEnded(); + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientMessageDeduplicator.java b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientMessageDeduplicator.java new file mode 100644 index 00000000..f7b1b956 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientMessageDeduplicator.java @@ -0,0 +1,123 @@ +package dev.caskeleton.grpc.advanced.streaming; + +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Decides what to do with a client stream message that may have arrived before. + * + *

Checkpoint-based rather than a set of seen keys. A set grows without bound for the life of a + * session and answers "have I seen this" — which is not quite the question. The question is "has + * this been applied", and a monotonic applied-sequence answers it in constant space and survives + * the process restart that a set does not. + * + *

Replayed outcomes are kept for the small window after the checkpoint, so a duplicate that + * arrives before the checkpoint advances gets the original answer rather than being reapplied. + */ +public final class GrpcClientMessageDeduplicator { + + private final ConcurrentMap checkpoints = + new ConcurrentHashMap<>(); + private final ConcurrentMap replayableOutcomes = new ConcurrentHashMap<>(); + + /** What to do with an incoming message. */ + public enum Disposition { + /** Not seen; apply it. */ + APPLY, + /** Already applied; return the stored outcome if there is one, otherwise ignore. */ + REPLAY, + /** Out of order ahead of the checkpoint; the sender skipped something. */ + GAP + } + + /** Starts tracking a session. */ + public void beginSession(GrpcClientStreamSessionId sessionId, Instant at) { + if (sessionId == null || at == null) { + throw new IllegalArgumentException("a session needs an id and a moment"); + } + checkpoints.putIfAbsent(sessionId.value(), GrpcClientStreamCheckpoint.empty(sessionId, at)); + } + + /** + * What to do with {@code message}. + * + * @throws IllegalStateException when the session is not being tracked, which means a message + * arrived for a session the server never opened + */ + public Disposition dispositionOf(GrpcClientStreamMessage message) { + if (message == null) { + throw new IllegalArgumentException("a message is required"); + } + GrpcClientStreamCheckpoint checkpoint = requireCheckpoint(message.sessionId()); + if (checkpoint.alreadyApplied(message.sequence())) { + return Disposition.REPLAY; + } + if (message.sequence() > checkpoint.lastAppliedSequence() + 1) { + return Disposition.GAP; + } + return Disposition.APPLY; + } + + /** + * Records that a message was applied, together with the outcome a duplicate would receive. + * + *

The application effect and this checkpoint belong in one transaction wherever the datastore + * allows it. Committing them separately leaves a window in which the effect is durable and the + * checkpoint is not, and a reconnect in that window reapplies the message. + */ + public void recordApplied( + GrpcClientStreamMessage message, String outcomeReference, Instant at) { + GrpcClientStreamCheckpoint checkpoint = requireCheckpoint(message.sessionId()); + checkpoints.put(message.sessionId().value(), checkpoint.advancedTo(message.sequence(), at)); + if (outcomeReference != null && !outcomeReference.isBlank()) { + replayableOutcomes.put(message.dedupKey(), outcomeReference); + } + } + + /** The stored outcome for a duplicate, when one was recorded. */ + public Optional replayOutcome(GrpcClientStreamMessage message) { + return Optional.ofNullable(replayableOutcomes.get(message.dedupKey())); + } + + /** The checkpoint for a session. */ + public Optional checkpointOf(GrpcClientStreamSessionId sessionId) { + return Optional.ofNullable(checkpoints.get(sessionId.value())); + } + + /** Whether a reconnecting client may continue. */ + public GrpcClientStreamResumeDecision decideResume( + GrpcClientStreamSessionId presented, String presentedCaller, String sessionOwner) { + if (presented == null) { + throw new IllegalArgumentException("a resume needs a presented session"); + } + if (presentedCaller == null || !presentedCaller.equals(sessionOwner)) { + return GrpcClientStreamResumeDecision.reject( + "the presented session belongs to a different caller"); + } + GrpcClientStreamCheckpoint checkpoint = checkpoints.get(presented.value()); + if (checkpoint == null) { + return GrpcClientStreamResumeDecision.newSession( + "the server holds no checkpoint for this session; resuming would leave its prefix either " + + "lost or applied twice, with nothing to tell which"); + } + return GrpcClientStreamResumeDecision.resume( + checkpoint.lastAppliedSequence(), "continuing from the last applied message"); + } + + /** Forgets a finished session. */ + public void endSession(GrpcClientStreamSessionId sessionId) { + checkpoints.remove(sessionId.value()); + replayableOutcomes.keySet().removeIf(key -> key.startsWith(sessionId.value() + "|")); + } + + private GrpcClientStreamCheckpoint requireCheckpoint(GrpcClientStreamSessionId sessionId) { + GrpcClientStreamCheckpoint checkpoint = checkpoints.get(sessionId.value()); + if (checkpoint == null) { + throw new IllegalStateException( + "no session is open for '" + sessionId.value() + "'; call beginSession first"); + } + return checkpoint; + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamCheckpoint.java b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamCheckpoint.java new file mode 100644 index 00000000..c9fc10fa --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamCheckpoint.java @@ -0,0 +1,56 @@ +package dev.caskeleton.grpc.advanced.streaming; + +import java.time.Instant; + +/** + * How far a client stream's messages have actually been applied. + * + *

Applied, not received. The distinction is the whole contract: the transport acknowledging a + * message means it reached the server's buffer, and a checkpoint means the application committed + * its effect. A resume that continues from a transport acknowledgement skips everything that was + * received and not yet applied when the connection died. + */ +public record GrpcClientStreamCheckpoint( + GrpcClientStreamSessionId sessionId, long lastAppliedSequence, Instant checkpointedAt) { + + /** Requires a session, a non-negative position and a moment. */ + public GrpcClientStreamCheckpoint { + if (sessionId == null) { + throw new IllegalArgumentException("a checkpoint belongs to a session"); + } + if (lastAppliedSequence < 0) { + throw new IllegalArgumentException("an applied sequence is at or after zero"); + } + if (checkpointedAt == null) { + throw new IllegalArgumentException("a checkpoint records when it was taken"); + } + } + + /** A session that has applied nothing yet. */ + public static GrpcClientStreamCheckpoint empty(GrpcClientStreamSessionId sessionId, Instant at) { + return new GrpcClientStreamCheckpoint(sessionId, 0L, at); + } + + /** + * Advances the checkpoint. + * + * @throws IllegalArgumentException when it would go backwards, which means two writers are + * checkpointing the same session + */ + public GrpcClientStreamCheckpoint advancedTo(long appliedSequence, Instant at) { + if (appliedSequence < lastAppliedSequence) { + throw new IllegalArgumentException( + "a checkpoint cannot move backwards from " + + lastAppliedSequence + + " to " + + appliedSequence + + "; two writers are checkpointing one session"); + } + return new GrpcClientStreamCheckpoint(sessionId, appliedSequence, at); + } + + /** Whether a message at {@code sequence} has already been applied. */ + public boolean alreadyApplied(long sequence) { + return sequence <= lastAppliedSequence; + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamMessage.java b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamMessage.java new file mode 100644 index 00000000..5af58172 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamMessage.java @@ -0,0 +1,32 @@ +package dev.caskeleton.grpc.advanced.streaming; + +/** + * One message a client sends, carrying the session and sequence that make it identifiable. + * + *

The sequence is the client's, not the transport's. gRPC guarantees ordering within a stream + * and says nothing about a stream that was re-established; a client that reconnects and resends + * needs its own numbering for the server to tell a resend from a new message. + * + * @param the payload type + */ +public record GrpcClientStreamMessage( + GrpcClientStreamSessionId sessionId, long sequence, T payload) { + + /** Requires a session, a one-based sequence and a payload. */ + public GrpcClientStreamMessage { + if (sessionId == null) { + throw new IllegalArgumentException("a client stream message belongs to a session"); + } + if (sequence < 1) { + throw new IllegalArgumentException("client stream sequences are 1-based; got " + sequence); + } + if (payload == null) { + throw new IllegalArgumentException("a client stream message carries a payload"); + } + } + + /** The dedup key for this message. */ + public String dedupKey() { + return sessionId.dedupKey(sequence); + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamPolicy.java b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamPolicy.java new file mode 100644 index 00000000..90a96b19 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamPolicy.java @@ -0,0 +1,50 @@ +package dev.caskeleton.grpc.advanced.streaming; + +import java.time.Duration; + +/** + * The bounds a client stream runs under. + * + *

A client stream is an inbound firehose with a single response at the end, so all four bounds + * are about the client rather than the server: how long it may hold the stream, how long it may go + * quiet, how fast it may send, and how much it may have unacknowledged. Without them, one client + * can occupy a server thread indefinitely while sending nothing. + * + *

Whole-stream transparent retry is not a setting. It is refused, because replaying a stream the + * server partially applied applies its prefix twice. + */ +public record GrpcClientStreamPolicy( + Duration maxDuration, Duration idleTimeout, int maxMessagesPerSecond, int maxInFlightMessages) { + + /** Refuses an unbounded client stream. */ + public GrpcClientStreamPolicy { + if (maxDuration == null || maxDuration.isZero() || maxDuration.isNegative()) { + throw new IllegalArgumentException("a client stream needs a maximum duration"); + } + if (idleTimeout == null || idleTimeout.isZero() || idleTimeout.isNegative()) { + throw new IllegalArgumentException("a client stream needs an idle timeout"); + } + if (idleTimeout.compareTo(maxDuration) > 0) { + throw new IllegalArgumentException( + "an idle timeout longer than the max duration never fires"); + } + if (maxMessagesPerSecond < 1 || maxInFlightMessages < 1) { + throw new IllegalArgumentException("message rate and in-flight bounds must be positive"); + } + } + + /** A default for an upload-shaped client stream. */ + public static GrpcClientStreamPolicy standard() { + return new GrpcClientStreamPolicy(Duration.ofMinutes(10), Duration.ofSeconds(30), 500, 64); + } + + /** + * Whether a whole client stream may be transparently retried. + * + *

Always false. A stream whose prefix the server already applied cannot be replayed without + * applying that prefix again, and the transport has no way to know how much was applied. + */ + public boolean wholeStreamRetryAllowed() { + return false; + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamResumeDecision.java b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamResumeDecision.java new file mode 100644 index 00000000..0a0bd343 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamResumeDecision.java @@ -0,0 +1,58 @@ +package dev.caskeleton.grpc.advanced.streaming; + +import java.util.Optional; + +/** + * Whether a reconnecting client may continue its session. + * + *

{@link Verdict#NEW_SESSION_REQUIRED} is what a server says when it no longer holds the + * checkpoint. Letting the client resume anyway would mean accepting messages numbered from where it + * thinks it stopped, against a server that has no record of the prefix — so the prefix is either + * lost or applied twice, and nothing detects which. + */ +public record GrpcClientStreamResumeDecision( + Verdict verdict, Optional resumeFromSequence, String reason) { + + /** What the server decided. */ + public enum Verdict { + /** Continue from the checkpoint. */ + RESUME, + /** The session is gone; start a new one and resend everything. */ + NEW_SESSION_REQUIRED, + /** The presented session does not belong to this caller. */ + REJECTED + } + + /** Requires a sequence only on RESUME. */ + public GrpcClientStreamResumeDecision { + if (verdict == null || resumeFromSequence == null) { + throw new IllegalArgumentException( + "a resume decision has a verdict and the sequence Optional"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("a resume decision explains itself"); + } + if (verdict == Verdict.RESUME && resumeFromSequence.isEmpty()) { + throw new IllegalArgumentException("a RESUME decision says where to continue from"); + } + if (verdict != Verdict.RESUME && resumeFromSequence.isPresent()) { + throw new IllegalArgumentException("only a RESUME decision carries a sequence"); + } + } + + /** Continue from {@code sequence}. */ + public static GrpcClientStreamResumeDecision resume(long sequence, String reason) { + return new GrpcClientStreamResumeDecision(Verdict.RESUME, Optional.of(sequence), reason); + } + + /** Start over. */ + public static GrpcClientStreamResumeDecision newSession(String reason) { + return new GrpcClientStreamResumeDecision( + Verdict.NEW_SESSION_REQUIRED, Optional.empty(), reason); + } + + /** Refuse the presented session. */ + public static GrpcClientStreamResumeDecision reject(String reason) { + return new GrpcClientStreamResumeDecision(Verdict.REJECTED, Optional.empty(), reason); + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamSessionId.java b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamSessionId.java new file mode 100644 index 00000000..1292ed34 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamSessionId.java @@ -0,0 +1,46 @@ +package dev.caskeleton.grpc.advanced.streaming; + +import java.util.UUID; + +/** + * A client stream's identity, plus the generation a reconnect advances. + * + *

The generation is what makes dedup possible across a reconnect. Message 7 of generation 1 and + * message 7 of generation 2 are different messages; a dedup key that ignores the generation drops + * the second, and the caller's seventh message is silently never applied. + */ +public record GrpcClientStreamSessionId(String value, long generation) { + + /** Requires a bounded id and a positive generation. */ + public GrpcClientStreamSessionId { + if (value == null || value.isBlank() || value.length() > 64) { + throw new IllegalArgumentException("a session id is a bounded non-blank identifier"); + } + if (generation < 1) { + throw new IllegalArgumentException("session generations are 1-based; got " + generation); + } + } + + /** A fresh session. */ + public static GrpcClientStreamSessionId newSession() { + return new GrpcClientStreamSessionId(UUID.randomUUID().toString(), 1L); + } + + /** The same session, resumed. */ + public GrpcClientStreamSessionId resumed() { + return new GrpcClientStreamSessionId(value, generation + 1); + } + + /** Whether {@code other} is the same logical session in any generation. */ + public boolean sameSessionAs(GrpcClientStreamSessionId other) { + return other != null && value.equals(other.value()); + } + + /** The dedup key for a message at {@code sequence} in this generation. */ + public String dedupKey(long sequence) { + if (sequence < 1) { + throw new IllegalArgumentException("client stream sequences are 1-based"); + } + return value + "|" + generation + "|" + sequence; + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamState.java b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamState.java new file mode 100644 index 00000000..17f64966 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamState.java @@ -0,0 +1,36 @@ +package dev.caskeleton.grpc.advanced.streaming; + +/** + * Where a client stream is in its life. + * + *

{@link #HALF_CLOSED} is separate from {@link #COMPLETED} because the server is still working + * between them: the client has said it will send nothing more, and the single response has not + * arrived. A state machine that merges them cannot express "we are waiting for the result of what + * we sent", which is the only interesting moment in a client stream. + */ +public enum GrpcClientStreamState { + /** Accepting messages from the client. */ + OPEN(true), + /** The client has finished sending; the server is producing its response. */ + HALF_CLOSED(false), + /** The server responded. */ + COMPLETED(false), + /** The stream ended without a response. */ + CANCELLED(false); + + private final boolean acceptsMessages; + + GrpcClientStreamState(boolean acceptsMessages) { + this.acceptsMessages = acceptsMessages; + } + + /** Whether another client message may be accepted. */ + public boolean acceptsMessages() { + return acceptsMessages; + } + + /** Whether the stream has finished, one way or another. */ + public boolean terminal() { + return this == COMPLETED || this == CANCELLED; + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcDemandController.java b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcDemandController.java new file mode 100644 index 00000000..e8da26ca --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcDemandController.java @@ -0,0 +1,105 @@ +package dev.caskeleton.grpc.advanced.streaming; + +import java.time.Duration; +import java.time.Instant; + +/** + * Tracks demand in one direction and refuses to let it grow past its bound. + * + *

Never returns the underlying observer. Handing an application the raw {@code + * ServerCallStreamObserver} gives it {@code request(n)} with no ceiling and {@code onNext} with no + * serialization, which is both bounds gone at once; this controller is what the application talks + * to instead. + * + *

The watchdog compares two moments rather than sleeping: when demand was last requested, and + * when a message last moved. Both stalling for the timeout is the deadlock. + */ +public final class GrpcDemandController { + + private final GrpcManualFlowControlPolicy policy; + private int outstandingDemand; + private Instant lastDemandRequestedAt; + private Instant lastMessageAt; + + /** Starts a controller at {@code startedAt} with no outstanding demand. */ + public GrpcDemandController(GrpcManualFlowControlPolicy policy, Instant startedAt) { + if (policy == null || startedAt == null) { + throw new IllegalArgumentException("a demand controller needs a policy and a start moment"); + } + this.policy = policy; + this.lastDemandRequestedAt = startedAt; + this.lastMessageAt = startedAt; + } + + /** + * Whether more may be requested at {@code now}. + * + * @param peerWaiting whether the other direction is itself blocked waiting for this one + */ + public synchronized GrpcDemandDecision decide(Instant now, boolean peerWaiting) { + if (now == null) { + throw new IllegalArgumentException("a demand decision needs a moment"); + } + if (peerWaiting && stalledFor(now, policy.deadlockWatchdogTimeout())) { + return new GrpcDemandDecision( + GrpcDemandDecision.Action.DEADLOCK_SUSPECTED, + outstandingDemand, + "neither direction has moved for " + + policy.deadlockWatchdogTimeout() + + " while both are waiting; the stream is open and will never progress"); + } + if (outstandingDemand >= policy.maxOutstandingDemand()) { + return new GrpcDemandDecision( + GrpcDemandDecision.Action.HOLD, + outstandingDemand, + "outstanding demand is at its ceiling of " + policy.maxOutstandingDemand()); + } + return new GrpcDemandDecision( + GrpcDemandDecision.Action.REQUEST_MORE, outstandingDemand, "within the demand ceiling"); + } + + /** + * Records a request for {@code count} more messages. + * + * @throws IllegalArgumentException when it would exceed the ceiling + */ + public synchronized void request(int count, Instant now) { + if (count < 1) { + throw new IllegalArgumentException("a demand request is for at least one message"); + } + if (outstandingDemand + count > policy.maxOutstandingDemand()) { + throw new IllegalArgumentException( + "requesting " + + count + + " more would take outstanding demand to " + + (outstandingDemand + count) + + ", above the ceiling of " + + policy.maxOutstandingDemand()); + } + outstandingDemand += count; + lastDemandRequestedAt = now; + } + + /** Records that one message arrived, consuming a unit of demand. */ + public synchronized void messageReceived(Instant now) { + if (outstandingDemand > 0) { + outstandingDemand--; + } + lastMessageAt = now; + } + + /** How much demand is outstanding. */ + public synchronized int outstandingDemand() { + return outstandingDemand; + } + + /** The high-water mark a metric records. */ + public synchronized int demandCeiling() { + return policy.maxOutstandingDemand(); + } + + private boolean stalledFor(Instant now, Duration timeout) { + return !now.isBefore(lastMessageAt.plus(timeout)) + && !now.isBefore(lastDemandRequestedAt.plus(timeout)); + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcDemandDecision.java b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcDemandDecision.java new file mode 100644 index 00000000..5815b01e --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcDemandDecision.java @@ -0,0 +1,40 @@ +package dev.caskeleton.grpc.advanced.streaming; + +/** + * Whether more inbound messages may be requested, or more outbound produced. + * + *

{@link Action#DEADLOCK_SUSPECTED} is a real answer rather than a diagnostic. Manual flow + * control makes it possible for both sides to be waiting for the other, and that state has no + * timeout of its own: the stream is healthy, the connection is open, and nothing will ever move + * again. Naming it is what lets a watchdog end the stream instead of leaking it. + */ +public record GrpcDemandDecision(Action action, int outstandingDemand, String reason) { + + /** What the caller should do. */ + public enum Action { + /** Request more inbound messages, or produce more outbound ones. */ + REQUEST_MORE, + /** Hold: the outstanding demand is already at its bound. */ + HOLD, + /** Neither side can move; end the stream. */ + DEADLOCK_SUSPECTED + } + + /** Requires a reason and a non-negative demand. */ + public GrpcDemandDecision { + if (action == null) { + throw new IllegalArgumentException("a demand decision has an action"); + } + if (outstandingDemand < 0) { + throw new IllegalArgumentException("outstanding demand must not be negative"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("a demand decision explains itself"); + } + } + + /** Whether the caller may ask for more. */ + public boolean mayRequestMore() { + return action == Action.REQUEST_MORE; + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcManualFlowControlPolicy.java b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcManualFlowControlPolicy.java new file mode 100644 index 00000000..4903f341 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcManualFlowControlPolicy.java @@ -0,0 +1,45 @@ +package dev.caskeleton.grpc.advanced.streaming; + +import java.time.Duration; + +/** + * The bounds a manually flow-controlled stream runs under. + * + *

Manual flow control hands the application the {@code request(n)} call, which is exactly the + * knob automatic flow control exists to turn correctly. The two bounds here are what keep that from + * becoming unbounded buffering: a ceiling on outstanding demand, and a watchdog for the state where + * both sides are waiting. + * + *

Approval is a field because this capability is granted per method, not per service. A method + * that reads a large result set benefits; the one next to it does not, and enabling both because + * they share a service is how the second one acquires a bug nobody was looking for. + */ +public record GrpcManualFlowControlPolicy( + int maxOutstandingDemand, Duration deadlockWatchdogTimeout, boolean approvedForMethod) { + + /** Refuses an unapproved or unbounded manual-flow-control policy. */ + public GrpcManualFlowControlPolicy { + if (maxOutstandingDemand < 1) { + throw new IllegalArgumentException( + "manual flow control needs a demand ceiling; without one, request(n) is unbounded " + + "buffering with extra steps"); + } + if (deadlockWatchdogTimeout == null + || deadlockWatchdogTimeout.isZero() + || deadlockWatchdogTimeout.isNegative()) { + throw new IllegalArgumentException( + "manual flow control needs a watchdog; a stream where both sides wait for the other has " + + "no timeout of its own"); + } + if (!approvedForMethod) { + throw new IllegalArgumentException( + "manual flow control is granted per method; an unapproved policy would enable it for " + + "every method on the service"); + } + } + + /** A policy for an approved method. */ + public static GrpcManualFlowControlPolicy forApprovedMethod(int maxOutstandingDemand) { + return new GrpcManualFlowControlPolicy(maxOutstandingDemand, Duration.ofSeconds(30), true); + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/test/java/dev/caskeleton/grpc/advanced/streaming/GrpcBidiSequenceTrackerTest.java b/src/grpc-advanced/grpc-advanced-streaming/src/test/java/dev/caskeleton/grpc/advanced/streaming/GrpcBidiSequenceTrackerTest.java new file mode 100644 index 00000000..0eda9b25 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/test/java/dev/caskeleton/grpc/advanced/streaming/GrpcBidiSequenceTrackerTest.java @@ -0,0 +1,108 @@ +package dev.caskeleton.grpc.advanced.streaming; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.streaming.GrpcFlowControlPolicy; +import dev.caskeleton.grpc.streaming.GrpcSlowConsumerPolicy; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcBidiSequenceTrackerTest { + + @Test + @DisplayName("the two directions have independent sequences") + void directionsAreIndependent() { + GrpcBidiSequenceTracker tracker = new GrpcBidiSequenceTracker(); + + assertThat(tracker.nextClientSequence()).isEqualTo(1L); + assertThat(tracker.nextClientSequence()).isEqualTo(2L); + assertThat(tracker.nextServerSequence()).isEqualTo(1L); + assertThat(tracker.clientToServer().lastSequence()).isEqualTo(2L); + assertThat(tracker.serverToClient().lastSequence()).isEqualTo(1L); + } + + @Test + @DisplayName("half-closing one direction leaves the other open") + void halfClosingOneDirectionLeavesTheOtherOpen() { + GrpcBidiSequenceTracker tracker = new GrpcBidiSequenceTracker(); + tracker.halfCloseClient(); + + assertThat(tracker.clientToServer().ended()).isTrue(); + assertThat(tracker.serverToClient().ended()).isFalse(); + assertThat(tracker.bothEnded()).isFalse(); + assertThat(tracker.nextServerSequence()).isEqualTo(1L); + + tracker.halfCloseServer(); + assertThat(tracker.bothEnded()).isTrue(); + } + + @Test + @DisplayName("cancelling one direction leaves the other alone") + void cancellingOneDirectionLeavesTheOtherAlone() { + GrpcBidiSequenceTracker tracker = new GrpcBidiSequenceTracker(); + + tracker.cancelServer(); + + assertThat(tracker.serverToClient().cancelled()).isTrue(); + assertThat(tracker.clientToServer().ended()).isFalse(); + assertThat(tracker.nextClientSequence()).isEqualTo(1L); + } + + @Test + @DisplayName("a message after half-close is a protocol error, not a late arrival") + void aMessageAfterHalfCloseIsAnError() { + GrpcBidiSequenceTracker tracker = new GrpcBidiSequenceTracker(); + tracker.halfCloseClient(); + + assertThatThrownBy(tracker::nextClientSequence) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("protocol error"); + } + + @Test + @DisplayName("half-close and cancel are different endings") + void halfCloseAndCancelAreDifferent() { + assertThatThrownBy(() -> new GrpcBidiDirectionState(1L, true, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("different endings"); + assertThat(GrpcBidiDirectionState.open().cancel().ended()).isTrue(); + assertThat(GrpcBidiDirectionState.open().halfClose().ended()).isTrue(); + assertThatThrownBy(() -> GrpcBidiDirectionState.open().cancel().halfClose()) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("a bidi resume carries both applied positions and requires a matching generation") + void aBidiResumeCarriesBothPositions() { + GrpcClientStreamSessionId session = GrpcClientStreamSessionId.newSession(); + GrpcBidiSession bidi = + new GrpcBidiSession( + session, GrpcFlowControlPolicy.stable(), GrpcFlowControlPolicy.stable()); + bidi.sequences().nextClientSequence(); + bidi.sequences().nextServerSequence(); + bidi.sequences().nextServerSequence(); + + GrpcBidiResumeState state = bidi.resumeState(); + + assertThat(state.clientAppliedSequence()).isEqualTo(1L); + assertThat(state.serverAppliedSequence()).isEqualTo(2L); + assertThat(bidi.resumeFrom(session)).isPresent(); + assertThat(bidi.resumeFrom(session.resumed())).isEmpty(); + assertThat(state.resumed().sessionId().generation()).isEqualTo(2L); + } + + @Test + @DisplayName("each direction carries its own flow-control policy") + void eachDirectionHasItsOwnFlowControl() { + GrpcFlowControlPolicy inbound = + new GrpcFlowControlPolicy(8, 1024L, 4, GrpcSlowConsumerPolicy.TERMINATE); + GrpcBidiSession bidi = + new GrpcBidiSession( + GrpcClientStreamSessionId.newSession(), inbound, GrpcFlowControlPolicy.stable()); + + assertThat(bidi.inboundFlowControl().maxQueuedMessages()).isEqualTo(8); + assertThat(bidi.outboundFlowControl().maxQueuedMessages()).isEqualTo(256); + assertThat(bidi.complete()).isFalse(); + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/test/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientMessageDeduplicatorTest.java b/src/grpc-advanced/grpc-advanced-streaming/src/test/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientMessageDeduplicatorTest.java new file mode 100644 index 00000000..f797f2eb --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/test/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientMessageDeduplicatorTest.java @@ -0,0 +1,130 @@ +package dev.caskeleton.grpc.advanced.streaming; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcClientMessageDeduplicatorTest { + + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + + private final GrpcClientMessageDeduplicator deduplicator = new GrpcClientMessageDeduplicator(); + + private static GrpcClientStreamMessage message( + GrpcClientStreamSessionId session, long sequence) { + return new GrpcClientStreamMessage<>(session, sequence, "payload-" + sequence); + } + + @Test + @DisplayName("a message is applied once, and a duplicate replays instead") + void aDuplicateReplaysRatherThanReapplying() { + GrpcClientStreamSessionId session = GrpcClientStreamSessionId.newSession(); + deduplicator.beginSession(session, NOW); + GrpcClientStreamMessage first = message(session, 1L); + + assertThat(deduplicator.dispositionOf(first)) + .isEqualTo(GrpcClientMessageDeduplicator.Disposition.APPLY); + deduplicator.recordApplied(first, "outcome://applied/1", NOW); + + assertThat(deduplicator.dispositionOf(first)) + .isEqualTo(GrpcClientMessageDeduplicator.Disposition.REPLAY); + assertThat(deduplicator.replayOutcome(first)).contains("outcome://applied/1"); + } + + @Test + @DisplayName("a message ahead of the checkpoint is a gap, not an application") + void aMessageAheadOfTheCheckpointIsAGap() { + GrpcClientStreamSessionId session = GrpcClientStreamSessionId.newSession(); + deduplicator.beginSession(session, NOW); + + assertThat(deduplicator.dispositionOf(message(session, 5L))) + .isEqualTo(GrpcClientMessageDeduplicator.Disposition.GAP); + } + + @Test + @DisplayName("a message for a session the server never opened is an error") + void anUnknownSessionIsAnError() { + GrpcClientStreamSessionId session = GrpcClientStreamSessionId.newSession(); + + assertThatThrownBy(() -> deduplicator.dispositionOf(message(session, 1L))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("beginSession"); + } + + @Test + @DisplayName("a checkpoint records what was applied, not what was received") + void aCheckpointRecordsWhatWasApplied() { + GrpcClientStreamSessionId session = GrpcClientStreamSessionId.newSession(); + GrpcClientStreamCheckpoint checkpoint = GrpcClientStreamCheckpoint.empty(session, NOW); + + assertThat(checkpoint.alreadyApplied(1L)).isFalse(); + assertThat(checkpoint.advancedTo(3L, NOW).alreadyApplied(3L)).isTrue(); + assertThatThrownBy(() -> checkpoint.advancedTo(3L, NOW).advancedTo(1L, NOW)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("two writers"); + } + + @Test + @DisplayName("a session the server no longer holds requires a new session, not a resume") + void aForgottenSessionRequiresANewOne() { + GrpcClientStreamSessionId session = GrpcClientStreamSessionId.newSession(); + + GrpcClientStreamResumeDecision decision = + deduplicator.decideResume(session, "tenant-1", "tenant-1"); + + assertThat(decision.verdict()) + .isEqualTo(GrpcClientStreamResumeDecision.Verdict.NEW_SESSION_REQUIRED); + assertThat(decision.reason()).contains("nothing to tell which"); + } + + @Test + @DisplayName("a session presented by a different caller is refused") + void aSessionFromAnotherCallerIsRefused() { + GrpcClientStreamSessionId session = GrpcClientStreamSessionId.newSession(); + deduplicator.beginSession(session, NOW); + + assertThat(deduplicator.decideResume(session, "tenant-2", "tenant-1").verdict()) + .isEqualTo(GrpcClientStreamResumeDecision.Verdict.REJECTED); + } + + @Test + @DisplayName("a tracked session resumes from its last applied message") + void aTrackedSessionResumes() { + GrpcClientStreamSessionId session = GrpcClientStreamSessionId.newSession(); + deduplicator.beginSession(session, NOW); + deduplicator.recordApplied(message(session, 1L), null, NOW); + deduplicator.recordApplied(message(session, 2L), null, NOW); + + GrpcClientStreamResumeDecision decision = + deduplicator.decideResume(session, "tenant-1", "tenant-1"); + + assertThat(decision.verdict()).isEqualTo(GrpcClientStreamResumeDecision.Verdict.RESUME); + assertThat(decision.resumeFromSequence()).contains(2L); + assertThat(deduplicator.checkpointOf(session)) + .hasValueSatisfying(c -> assertThat(c.lastAppliedSequence()).isEqualTo(2L)); + } + + @Test + @DisplayName("a finished session is forgotten along with its replayable outcomes") + void endingASessionForgetsIt() { + GrpcClientStreamSessionId session = GrpcClientStreamSessionId.newSession(); + deduplicator.beginSession(session, NOW); + deduplicator.recordApplied(message(session, 1L), "outcome://applied/1", NOW); + + deduplicator.endSession(session); + + assertThat(deduplicator.checkpointOf(session)).isEmpty(); + assertThat(deduplicator.replayOutcome(message(session, 1L))).isEmpty(); + } + + @Test + @DisplayName("only a RESUME decision carries a sequence") + void onlyResumeCarriesASequence() { + assertThat(GrpcClientStreamResumeDecision.resume(4L, "ok").resumeFromSequence()).contains(4L); + assertThat(GrpcClientStreamResumeDecision.newSession("gone").resumeFromSequence()).isEmpty(); + assertThat(GrpcClientStreamResumeDecision.reject("not yours").resumeFromSequence()).isEmpty(); + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/test/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamPolicyTest.java b/src/grpc-advanced/grpc-advanced-streaming/src/test/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamPolicyTest.java new file mode 100644 index 00000000..4dd3a59c --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/test/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientStreamPolicyTest.java @@ -0,0 +1,90 @@ +package dev.caskeleton.grpc.advanced.streaming; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcClientStreamPolicyTest { + + @Test + @DisplayName("a dedup key includes the generation, so a resend is not mistaken for a duplicate") + void dedupKeysIncludeTheGeneration() { + GrpcClientStreamSessionId first = GrpcClientStreamSessionId.newSession(); + GrpcClientStreamSessionId resumed = first.resumed(); + + assertThat(first.dedupKey(7L)).isNotEqualTo(resumed.dedupKey(7L)); + assertThat(resumed.sameSessionAs(first)).isTrue(); + assertThat(resumed.generation()).isEqualTo(2L); + } + + @Test + @DisplayName("a session id and a sequence are both bounded and one-based") + void identifiersAreBounded() { + GrpcClientStreamSessionId session = GrpcClientStreamSessionId.newSession(); + + assertThat(session.generation()).isEqualTo(1L); + assertThatThrownBy(() -> session.dedupKey(0L)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new GrpcClientStreamSessionId("", 1L)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new GrpcClientStreamSessionId("s", 0L)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a message carries its session and a one-based client sequence") + void aMessageCarriesItsPosition() { + GrpcClientStreamSessionId session = GrpcClientStreamSessionId.newSession(); + GrpcClientStreamMessage message = new GrpcClientStreamMessage<>(session, 3L, "payload"); + + assertThat(message.dedupKey()).isEqualTo(session.dedupKey(3L)); + assertThatThrownBy(() -> new GrpcClientStreamMessage<>(session, 0L, "payload")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new GrpcClientStreamMessage<>(session, 1L, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("half-close is a state of its own, distinct from completion") + void halfCloseIsItsOwnState() { + assertThat(GrpcClientStreamState.OPEN.acceptsMessages()).isTrue(); + assertThat(GrpcClientStreamState.HALF_CLOSED.acceptsMessages()).isFalse(); + assertThat(GrpcClientStreamState.HALF_CLOSED.terminal()).isFalse(); + assertThat(GrpcClientStreamState.COMPLETED.terminal()).isTrue(); + assertThat(GrpcClientStreamState.CANCELLED.terminal()).isTrue(); + } + + @Test + @DisplayName("a whole client stream is never transparently retried") + void aClientStreamIsNeverWhollyRetried() { + assertThat(GrpcClientStreamPolicy.standard().wholeStreamRetryAllowed()).isFalse(); + } + + @Test + @DisplayName("an unbounded client stream policy is refused") + void anUnboundedClientStreamIsRefused() { + assertThatThrownBy( + () -> new GrpcClientStreamPolicy(Duration.ZERO, Duration.ofSeconds(1), 10, 10)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> new GrpcClientStreamPolicy(Duration.ofMinutes(1), Duration.ofMinutes(5), 10, 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("never fires"); + assertThatThrownBy( + () -> new GrpcClientStreamPolicy(Duration.ofMinutes(5), Duration.ofSeconds(30), 0, 10)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the standard policy bounds duration, idle, rate and in-flight together") + void theStandardPolicyBoundsEverything() { + GrpcClientStreamPolicy standard = GrpcClientStreamPolicy.standard(); + + assertThat(standard.maxDuration()).isEqualTo(Duration.ofMinutes(10)); + assertThat(standard.idleTimeout()).isEqualTo(Duration.ofSeconds(30)); + assertThat(standard.maxMessagesPerSecond()).isPositive(); + assertThat(standard.maxInFlightMessages()).isPositive(); + } +} diff --git a/src/grpc-advanced/grpc-advanced-streaming/src/test/java/dev/caskeleton/grpc/advanced/streaming/GrpcDemandControllerTest.java b/src/grpc-advanced/grpc-advanced-streaming/src/test/java/dev/caskeleton/grpc/advanced/streaming/GrpcDemandControllerTest.java new file mode 100644 index 00000000..f54a6409 --- /dev/null +++ b/src/grpc-advanced/grpc-advanced-streaming/src/test/java/dev/caskeleton/grpc/advanced/streaming/GrpcDemandControllerTest.java @@ -0,0 +1,101 @@ +package dev.caskeleton.grpc.advanced.streaming; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcDemandControllerTest { + + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + + @Test + @DisplayName("manual flow control is granted per method and needs a demand ceiling") + void manualFlowControlIsGrantedPerMethod() { + assertThatThrownBy(() -> new GrpcManualFlowControlPolicy(16, Duration.ofSeconds(30), false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("granted per method"); + assertThatThrownBy(() -> new GrpcManualFlowControlPolicy(0, Duration.ofSeconds(30), true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unbounded buffering"); + assertThatThrownBy(() -> new GrpcManualFlowControlPolicy(16, Duration.ZERO, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("no timeout of its own"); + } + + @Test + @DisplayName("demand is bounded, and requesting past the ceiling is refused") + void demandIsBounded() { + GrpcDemandController controller = + new GrpcDemandController(GrpcManualFlowControlPolicy.forApprovedMethod(4), NOW); + + controller.request(4, NOW); + assertThat(controller.outstandingDemand()).isEqualTo(4); + assertThat(controller.demandCeiling()).isEqualTo(4); + assertThat(controller.decide(NOW, false).action()).isEqualTo(GrpcDemandDecision.Action.HOLD); + assertThatThrownBy(() -> controller.request(1, NOW)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("above the ceiling"); + + controller.messageReceived(NOW); + assertThat(controller.outstandingDemand()).isEqualTo(3); + assertThat(controller.decide(NOW, false).mayRequestMore()).isTrue(); + } + + @Test + @DisplayName("a request for fewer than one message is refused") + void aZeroRequestIsRefused() { + GrpcDemandController controller = + new GrpcDemandController(GrpcManualFlowControlPolicy.forApprovedMethod(4), NOW); + + assertThatThrownBy(() -> controller.request(0, NOW)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("both sides waiting past the watchdog is reported as a suspected deadlock") + void aMutualWaitIsReportedAsADeadlock() { + GrpcDemandController controller = + new GrpcDemandController(GrpcManualFlowControlPolicy.forApprovedMethod(4), NOW); + + assertThat(controller.decide(NOW.plusSeconds(29), true).action()) + .isEqualTo(GrpcDemandDecision.Action.REQUEST_MORE); + GrpcDemandDecision deadlocked = controller.decide(NOW.plusSeconds(30), true); + assertThat(deadlocked.action()).isEqualTo(GrpcDemandDecision.Action.DEADLOCK_SUSPECTED); + assertThat(deadlocked.reason()).contains("will never progress"); + assertThat(controller.decide(NOW.plusSeconds(30), false).mayRequestMore()).isTrue(); + } + + @Test + @DisplayName("activity postpones the watchdog") + void activityPostponesTheWatchdog() { + GrpcDemandController controller = + new GrpcDemandController(GrpcManualFlowControlPolicy.forApprovedMethod(4), NOW); + + controller.messageReceived(NOW.plusSeconds(20)); + + assertThat(controller.decide(NOW.plusSeconds(40), true).action()) + .isEqualTo(GrpcDemandDecision.Action.REQUEST_MORE); + } + + @Test + @DisplayName("the controller never exposes a raw observer") + void theControllerExposesNoRawObserver() { + assertThat(GrpcDemandController.class.getMethods()) + .extracting(java.lang.reflect.Method::getReturnType) + .extracting(Class::getName) + .noneMatch(name -> name.startsWith("io.grpc.")); + } + + @Test + @DisplayName("a demand decision always explains itself and carries a non-negative demand") + void aDecisionExplainsItself() { + assertThatThrownBy(() -> new GrpcDemandDecision(GrpcDemandDecision.Action.HOLD, 1, " ")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new GrpcDemandDecision(GrpcDemandDecision.Action.HOLD, -1, "why")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/grpc/CLAUDE.md b/src/grpc/CLAUDE.md new file mode 100644 index 00000000..5423a419 --- /dev/null +++ b/src/grpc/CLAUDE.md @@ -0,0 +1,117 @@ +# grpc — local authority for the gRPC platform family + +이 문서는 `grpc:*` family의 **local authority**다. leaf 목록·gradle path·허용 의존성은 +`src/config/architecture/modules.json`이 SSOT이며 이 문서는 그것을 복제하지 않는다. 여기서 정하는 +것은 registry가 표현할 수 없는 것들이다: family별 framework 규칙, 증거 등급, Stable 범위의 경계, +그리고 `runtime_memberships`가 비어 있다는 사실의 의미. + +Root 정책(`CLAUDE.md` / `AGENTS.md`)과 충돌하면 root가 이긴다. + +이 family는 Clean Architecture의 계층이 아니라 **벤더드 RPC 플랫폼**이다 — `messaging:*`와 같은 +자리에 있고 같은 이유로 있다. 자기 API(port)와 adapter와 조립 경계(starter)를 가지며, 애플리케이션은 +`adapter:inbound:grpc`를 통해 라이브러리로서 도달한다. + +설계 근거와 원본 계획 대비 deviation은 +[docs/superpowers/specs/2026-08-30-grpc-platform-adaptation-design.md](../../docs/superpowers/specs/2026-08-30-grpc-platform-adaptation-design.md)가 SSOT다. + +## Family와 framework 규칙 + +leaf의 정확한 id는 registry에서 읽는다. + +| Family | 역할 | 허용 framework | +| --- | --- | --- | +| `grpc-core-api` | 식별자, method policy, 실행 증거, 실패 모델, deadline, request context, operation ledger port | **Java stdlib만.** io.grpc / Spring / protobuf / DB 타입 금지 | +| `grpc-proto-contract` | `.proto` 스키마 소스와 style/schema 규칙 검증기 | Java stdlib만 | +| `grpc-codegen` | Buf governance, codegen manifest, descriptor 릴리스 아티팩트 | Java stdlib만 | +| `grpc-policy` | validation, context, status/rich error, TLS/credential, deadline/cancellation, retry, idempotency, streaming, size/compression | `io.grpc:grpc-api`, `grpc-stub`. Spring 금지 | +| `grpc-server` | application boundary rule, typed service adapter SPI, interceptor 순서, Netty profile | `io.grpc:grpc-api`, `grpc-stub`. Netty 자체 금지 — profile은 설정 모델이다 | +| `grpc-client` | named channel, runtime generation, typed stub factory, client metadata/credentials | `io.grpc:grpc-api`, `grpc-stub` | +| `grpc-discovery` | Static/DNS resolver, pick_first/round_robin, Kubernetes routing profile | Java stdlib만 | +| `grpc-admin` | health, reflection, drain, secret-free policy snapshot | Java stdlib만 | +| `grpc-observability` | bounded metric/trace convention | `io.micrometer:micrometer-core`만 | +| `grpc-operation-ledger-jpa` | mutation idempotency의 durable 저장 | `jakarta.persistence`, `spring-data-jpa`. **port는 core-api에 있다** | +| `grpc-spring-boot-starter` | 조립 경계, typed properties, startup validator | Spring Boot autoconfigure. 여기서만 | +| `grpc-testkit` | in-process / 실제 Netty / fault / performance 인증 | io.grpc netty-shaded, inprocess. production leaf가 의존하지 않는다 | + +`grpc-core-api`가 io.grpc를 이름조차 부르지 않는다는 것이 이 family의 핵심 제약이다. 실행 증거와 +실패 모델은 "전송이 무엇을 했는가"를 기술하는 계약이고, 그 계약 안에 전송 타입이 들어가면 계약이 +전송의 일부가 된다. `GrpcStatusCode`가 io.grpc의 코드를 미러링하는 이유가 이것이고, 양방향 번역은 +`grpc-policy`의 `GrpcStatusMapping`이 단독으로 소유한다. + +## 증거 등급 — testkit이 하나의 leaf인 이유 + +원본 계획은 testkit을 core/in-process/netty/fault 4개 모듈로 쪼갠다. 목적은 "in-process 결과를 +네트워크 증거로 오해하지 않게 한다"이고, 이 저장소는 그 목적을 모듈 경계가 아니라 **strict test +lane**으로 표현한다(`ca.strict-test-lane`: lane은 아무것도 실행하지 않으면 실패하고 up-to-date +결과를 제공하지 않는다). + +`grpc-testkit`의 네 lane과 그 태그: + +| lane | 태그 | 증명하는 것 | +| --- | --- | --- | +| `grpcInProcessContractTest` | `grpc-inprocess` | adapter, interceptor 순서, status, validation, idempotency replay | +| `grpcNettyContractTest` | `grpc-netty` | 실제 소켓 위의 HTTP/2, TLS/mTLS, metadata·message hard limit, GOAWAY, drain | +| `grpcFaultTest` | `grpc-fault` | 각 증거 경계에서의 연결 손실, completion unknown, partial stream | +| `grpcPerformanceTest` | `grpc-performance` | latency percentile, saturation, drain budget | + +lane 분리만으로는 리포트가 잘못된 run을 인용하는 것을 막지 못하므로 `GrpcEvidenceGrade`가 런타임 +체크로 이를 붙든다: `CONTRACT` 등급으로 `tls`를 주장하면 예외다. + +**performance lane은 기본 `test`에서 제외된다**(leaf `build.gradle`). 공유 CI runner에서의 측정은 +release path의 flaky gate가 되고, flaky gate는 꺼진다. + +## Stable 범위 + +Stable RPC 유형은 **Unary와 Server Streaming뿐**이다. Client Streaming, Bidirectional Streaming, +hedging, xDS, custom resolver/LB, gRPC-Web, Servlet, Reactor, Kotlin은 전부 `grpc-advanced:*`이며 +capability flag 뒤에 있다. `GrpcPlatformStartupValidator`가 Stable catalog에 streaming method가 +등록되면 startup을 거부한다. + +`grpc-spring-boot-starter`는 어떤 advanced module도 참조하지 않는다. registry의 +`allowed_dependencies`가 이를 build time에, `GrpcStableBuildInvariant`가 runtime에 강제한다. + +## Runtime membership이 비어 있다는 것의 의미 + +registry의 모든 `grpc:*` leaf는 현재 `runtime_memberships`가 비어 있다. `messaging:*`가 처음 +착지했을 때와 같은 상태다: + +- **배포 아티팩트가 싣고 있지 않다.** build-only다. +- 애플리케이션에 배선하려면 registry의 `runtime_memberships`를 먼저 바꾸고 + `verifyRuntimeModuleMembership`을 통과시켜야 한다. 코드만 추가하는 것으로는 런타임에 들어가지 않는다. +- **런타임에 있다는 것과 자격이 증명됐다는 것은 다르다.** 현재 in-process·Netty·fault lane은 실제로 + 실행되어 통과하지만, 실제 배포 환경에서의 soak·performance baseline은 없다. `GrpcStableReleaseGate`가 + 그 구분을 문서가 아니라 코드로 붙들고 있다. + +## application이 이 family에 도달하는 경로 + +`messaging:*`의 MSG-015(bridge 부재)를 반복하지 않는 것이 이 family의 목표다. 계약상의 경로는 + +```text +application-owned port → adapter:inbound:grpc (typed service adapter) → :grpc:grpc-server SPI +``` + +이고 `GrpcApplicationBoundaryRules`·`GrpcRawApiImportRule`·`GrpcServiceAdapter`가 그 경계를 +소유한다. 현재 `adapter:inbound:grpc`는 이 family에 의존하지 않는다 — registry의 +`allowed_dependencies`를 보라. 배선은 별도 결정이며, 그때 registry edge와 브리지가 함께 들어간다. + +## 테스트 명령 + +focused test는 registry의 `gradle_path`에서 파생한다. + +```bash +cd src +./gradlew :grpc::test --console=plain +./gradlew :grpc:grpc-testkit:grpcInProcessContractTest --console=plain +./gradlew :grpc:grpc-testkit:grpcNettyContractTest --console=plain +./gradlew :grpc:grpc-testkit:grpcFaultTest --console=plain +./gradlew :grpc:grpc-testkit:grpcPerformanceTest --console=plain +``` + +## 금지 + +- `grpc-core-api`에서 io.grpc / Spring / protobuf / JPA 타입 사용. +- Stable leaf에서 `:grpc-advanced:*` 참조. +- `grpc-testkit`을 production leaf가 의존하는 것. +- in-process 결과로 transport capability를 주장하는 것 (`GrpcEvidenceGrade`가 거부한다). +- metric tag에 raw metadata·payload·actor/tenant/object/stream/idempotency 식별자를 넣는 것 + (`GrpcMetricCardinalityPolicy`가 거부한다). diff --git a/src/grpc/grpc-admin/build.gradle b/src/grpc/grpc-admin/build.gradle new file mode 100644 index 00000000..f6d5fa56 --- /dev/null +++ b/src/grpc/grpc-admin/build.gradle @@ -0,0 +1,8 @@ +apply plugin: 'java-library' + +// Operational surface: the standard health registry, the reflection exposure policy, the drain +// coordinator, and the secret-free runtime policy snapshot an administrator reads. +dependencies { + api project(':grpc:grpc-core-api') + api project(':grpc:grpc-server') +} diff --git a/src/grpc/grpc-admin/gradle.lockfile b/src/grpc/grpc-admin/gradle.lockfile new file mode 100644 index 00000000..9369e831 --- /dev/null +++ b/src/grpc/grpc-admin/gradle.lockfile @@ -0,0 +1,91 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty= diff --git a/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcAdminExposurePolicy.java b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcAdminExposurePolicy.java new file mode 100644 index 00000000..cf384232 --- /dev/null +++ b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcAdminExposurePolicy.java @@ -0,0 +1,71 @@ +package dev.caskeleton.grpc.admin; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * What an admin snapshot is allowed to contain, and who may read one. + * + *

The denylist is keyed on field names rather than values because a snapshot is assembled from + * configuration, and configuration keys are named after what they hold. A key called {@code + * client-secret} does not need its value inspected to be refused. + * + *

Both gates are required for the same reason reflection needs both: an admin role reachable + * from the public network is not an admin gate. + */ +public record GrpcAdminExposurePolicy(Set adminNetworks, Set adminRoles) { + + private static final Pattern SECRET_FIELD = + Pattern.compile( + "(?i).*(password|secret|token|credential|private[_-]?key|api[_-]?key|passphrase|" + + "authorization|bearer).*"); + + /** Refuses a policy with nothing to check. */ + public GrpcAdminExposurePolicy { + if (adminNetworks == null || adminRoles == null) { + throw new IllegalArgumentException("an admin policy states both gates"); + } + adminNetworks = Set.copyOf(adminNetworks); + adminRoles = Set.copyOf(adminRoles); + if (adminNetworks.isEmpty() || adminRoles.isEmpty()) { + throw new IllegalArgumentException( + "an admin surface needs both a network and a role gate; either alone leaves the snapshot " + + "reachable by whoever satisfies the other"); + } + } + + /** This repository's default. */ + public static GrpcAdminExposurePolicy standard() { + return new GrpcAdminExposurePolicy(Set.of("admin"), Set.of("ROLE_PLATFORM_ADMIN")); + } + + /** Whether this caller may read an admin snapshot. */ + public boolean mayRead(String callerNetwork, Set callerRoles) { + return callerNetwork != null + && adminNetworks.contains(callerNetwork) + && callerRoles != null + && callerRoles.stream().anyMatch(adminRoles::contains); + } + + /** Whether a field name is one whose value must never appear in a snapshot. */ + public static boolean secretField(String fieldName) { + return fieldName != null && SECRET_FIELD.matcher(fieldName).matches(); + } + + /** + * Every field in {@code candidate} that must not be published. + * + * @return an empty list when the map is safe to expose + */ + public static List forbiddenFields(Map candidate) { + if (candidate == null) { + throw new IllegalArgumentException("a candidate snapshot map is required"); + } + return candidate.keySet().stream() + .filter(GrpcAdminExposurePolicy::secretField) + .sorted() + .toList(); + } +} diff --git a/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcDrainCoordinator.java b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcDrainCoordinator.java new file mode 100644 index 00000000..17942563 --- /dev/null +++ b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcDrainCoordinator.java @@ -0,0 +1,162 @@ +package dev.caskeleton.grpc.admin; + +import dev.caskeleton.grpc.server.GrpcAdmissionController; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.function.IntSupplier; + +/** + * Runs the shutdown sequence in order and reports what it achieved. + * + *

Deterministic on purpose. A shutdown assembled from lifecycle callbacks runs its steps in + * whatever order the container happens to resolve them, and the ordering bug — refusing before + * routing has stopped — only appears under real traffic, which is the one condition a test does not + * reproduce. + * + *

The coordinator does not sleep. It is given the current moment and the counts, and returns + * whether the phase is done; the waiting belongs to the caller, which is what makes every branch of + * this testable without a clock. + */ +public final class GrpcDrainCoordinator { + + private final GrpcServiceHealthRegistry health; + private final GrpcAdmissionController admission; + private final GrpcDrainPolicy policy; + private final IntSupplier inFlightUnaryCalls; + private final IntSupplier openStreams; + private final List phasesRun = new ArrayList<>(); + + private Instant startedAt; + private int completedUnaryCalls; + private int signalledStreams; + + /** + * @param inFlightUnaryCalls how many unary calls are still running, asked each time rather than + * captured, so the coordinator sees the number shrink + * @param openStreams how many streams are still open + */ + public GrpcDrainCoordinator( + GrpcServiceHealthRegistry health, + GrpcAdmissionController admission, + GrpcDrainPolicy policy, + IntSupplier inFlightUnaryCalls, + IntSupplier openStreams) { + if (health == null + || admission == null + || policy == null + || inFlightUnaryCalls == null + || openStreams == null) { + throw new IllegalArgumentException("a drain coordinator needs all five collaborators"); + } + this.health = health; + this.admission = admission; + this.policy = policy; + this.inFlightUnaryCalls = inFlightUnaryCalls; + this.openStreams = openStreams; + } + + /** + * Runs the first two phases: stop being routed to, then say why. + * + *

Nothing is refused yet, deliberately. + */ + public void beginDrain(Instant now) { + if (now == null) { + throw new IllegalArgumentException("a drain needs a starting moment"); + } + if (startedAt != null) { + return; + } + startedAt = now; + health.beginDraining(); + phasesRun.add(GrpcDrainPhase.READINESS_FALSE); + phasesRun.add(GrpcDrainPhase.HEALTH_DRAINING); + } + + /** + * Starts refusing new calls. + * + * @throws IllegalStateException when called before {@link #beginDrain}, which would refuse + * traffic that routing is still sending + */ + public void rejectNewAdmission() { + requireStarted(); + phasesRun.add(GrpcDrainPhase.REJECT_NEW_ADMISSION); + } + + /** + * Whether the unary drain is finished at {@code now}. + * + * @return true when nothing is in flight or the budget has elapsed + */ + public boolean unaryDrainComplete(Instant now) { + requireStarted(); + if (!phasesRun.contains(GrpcDrainPhase.DRAIN_UNARY)) { + phasesRun.add(GrpcDrainPhase.DRAIN_UNARY); + } + int remaining = inFlightUnaryCalls.getAsInt(); + if (remaining == 0) { + return true; + } + return elapsed(now).compareTo(policy.unaryDrainBudget()) >= 0; + } + + /** Records that each open stream has been sent its terminal message. */ + public void signalStreams() { + requireStarted(); + signalledStreams = openStreams.getAsInt(); + phasesRun.add(GrpcDrainPhase.SIGNAL_STREAMS); + } + + /** Whether the stream signal budget has elapsed at {@code now}. */ + public boolean streamSignalComplete(Instant now) { + requireStarted(); + return openStreams.getAsInt() == 0 + || elapsed(now).compareTo(policy.unaryDrainBudget().plus(policy.streamSignalBudget())) >= 0; + } + + /** Records how many unary calls finished on their own. */ + public void recordCompletedUnaryCalls(int completed) { + if (completed < 0) { + throw new IllegalArgumentException("a completed count must not be negative"); + } + this.completedUnaryCalls = completed; + } + + /** Cancels whatever is left and returns the report. */ + public GrpcDrainResult forceCancel(Instant now) { + requireStarted(); + phasesRun.add(GrpcDrainPhase.FORCE_CANCEL); + return new GrpcDrainResult( + completedUnaryCalls, + inFlightUnaryCalls.getAsInt(), + signalledStreams, + openStreams.getAsInt(), + elapsed(now), + phasesRun); + } + + /** Whether new calls are still being admitted. */ + public boolean admittingNewCalls() { + return !phasesRun.contains(GrpcDrainPhase.REJECT_NEW_ADMISSION); + } + + /** How many calls the admission controller currently holds. */ + public int inFlightAdmitted() { + return admission.inFlight(); + } + + private Duration elapsed(Instant now) { + return Duration.between(startedAt, now); + } + + private void requireStarted() { + if (startedAt == null) { + throw new IllegalStateException( + "the drain has not begun; refusing calls before readiness has flipped produces errors " + + "for traffic that routing is still sending"); + } + } +} diff --git a/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcDrainPhase.java b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcDrainPhase.java new file mode 100644 index 00000000..7d87d1fe --- /dev/null +++ b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcDrainPhase.java @@ -0,0 +1,28 @@ +package dev.caskeleton.grpc.admin; + +/** + * The shutdown sequence, in the order it must run. + * + *

Readiness comes first, before anything is refused, because there is a gap between an instance + * saying it is unready and a load balancer acting on it. Refusing during that gap turns a clean + * rollout into a burst of errors at every deployment. + * + *

Streams are signalled rather than waited for. A subscription may legitimately never end, so a + * drain that waits for one waits forever; what it gets instead is a terminal message carrying its + * resume cursor, which is the difference between a client that reconnects where it left off and one + * that resynchronises from scratch. + */ +public enum GrpcDrainPhase { + /** Report unready, so routing stops sending. Nothing is refused yet. */ + READINESS_FALSE, + /** Flip health to draining, which a load balancer reads differently from a failure. */ + HEALTH_DRAINING, + /** Refuse new calls. */ + REJECT_NEW_ADMISSION, + /** Wait for in-flight unary calls to finish. */ + DRAIN_UNARY, + /** Send each stream its terminal message and resume cursor. */ + SIGNAL_STREAMS, + /** Cancel whatever is left. */ + FORCE_CANCEL +} diff --git a/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcDrainPolicy.java b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcDrainPolicy.java new file mode 100644 index 00000000..f182926e --- /dev/null +++ b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcDrainPolicy.java @@ -0,0 +1,43 @@ +package dev.caskeleton.grpc.admin; + +import java.time.Duration; + +/** + * The two budgets a drain runs against. + * + *

Separate because they end differently. The drain budget is time given to work that will finish + * on its own; the force budget is time given to work being told to stop. One number for both means + * either cancelling calls that were about to complete, or waiting the full budget for streams that + * were never going to. + */ +public record GrpcDrainPolicy( + Duration unaryDrainBudget, Duration streamSignalBudget, Duration forceCancelBudget) { + + /** Refuses a policy with no bound on how long shutdown takes. */ + public GrpcDrainPolicy { + requireNonNegative(unaryDrainBudget, "unary drain budget"); + requireNonNegative(streamSignalBudget, "stream signal budget"); + requireNonNegative(forceCancelBudget, "force cancel budget"); + if (unaryDrainBudget.isZero() && streamSignalBudget.isZero() && forceCancelBudget.isZero()) { + throw new IllegalArgumentException( + "a drain with no budget at all cancels every in-flight call at the first phase"); + } + } + + private static void requireNonNegative(Duration value, String what) { + if (value == null || value.isNegative()) { + throw new IllegalArgumentException(what + " must be present and non-negative"); + } + } + + /** The Stable default. */ + public static GrpcDrainPolicy stable() { + return new GrpcDrainPolicy( + Duration.ofSeconds(15), Duration.ofSeconds(5), Duration.ofSeconds(5)); + } + + /** The longest a full drain can take. */ + public Duration totalBudget() { + return unaryDrainBudget.plus(streamSignalBudget).plus(forceCancelBudget); + } +} diff --git a/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcDrainResult.java b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcDrainResult.java new file mode 100644 index 00000000..d6a8fdaa --- /dev/null +++ b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcDrainResult.java @@ -0,0 +1,48 @@ +package dev.caskeleton.grpc.admin; + +import java.time.Duration; +import java.util.List; + +/** + * What a drain actually achieved. + * + *

Counted rather than assumed, because "graceful shutdown" is a claim and these are the numbers + * that support or refute it. A deployment whose drains routinely force-cancel is a deployment whose + * clients see errors at every rollout, and without the count that shows up as unexplained {@code + * CANCELLED} in a dashboard nobody connects to the deploy. + */ +public record GrpcDrainResult( + int completedUnaryCalls, + int cancelledUnaryCalls, + int signalledStreams, + int cancelledStreams, + Duration elapsed, + List phasesRun) { + + /** Requires non-negative counts and the phase list. */ + public GrpcDrainResult { + if (completedUnaryCalls < 0 + || cancelledUnaryCalls < 0 + || signalledStreams < 0 + || cancelledStreams < 0) { + throw new IllegalArgumentException("drain counts must not be negative"); + } + if (elapsed == null || elapsed.isNegative()) { + throw new IllegalArgumentException("a drain records how long it took"); + } + if (phasesRun == null || phasesRun.isEmpty()) { + throw new IllegalArgumentException("a drain records the phases it ran"); + } + phasesRun = List.copyOf(phasesRun); + } + + /** Whether anything had to be cut off. */ + public boolean forcedAnything() { + return cancelledUnaryCalls > 0 || cancelledStreams > 0; + } + + /** Whether every phase ran. */ + public boolean complete() { + return phasesRun.containsAll(List.of(GrpcDrainPhase.values())); + } +} diff --git a/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcHealthPolicy.java b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcHealthPolicy.java new file mode 100644 index 00000000..6c69a7ea --- /dev/null +++ b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcHealthPolicy.java @@ -0,0 +1,49 @@ +package dev.caskeleton.grpc.admin; + +import java.util.Set; + +/** + * Which dependencies a service's health actually depends on. + * + *

The distinction the policy exists to hold: a dependency belongs here only when the service + * cannot answer correctly without it. Reporting unhealthy because a non-essential dependency is + * down takes an instance out of rotation that could still serve most of its traffic — and in a + * shared dependency outage, it takes every instance out at once, turning a partial degradation into + * a total one. + */ +public record GrpcHealthPolicy( + Set correctnessCriticalDependencies, boolean readyBeforeFirstCheck) { + + /** Copies the dependency set. */ + public GrpcHealthPolicy { + if (correctnessCriticalDependencies == null) { + throw new IllegalArgumentException( + "a health policy states its critical dependencies, even if none"); + } + correctnessCriticalDependencies = Set.copyOf(correctnessCriticalDependencies); + if (readyBeforeFirstCheck) { + throw new IllegalArgumentException( + "readiness must be false until the first check completes; an instance that reports ready " + + "at process start receives traffic before it can serve it"); + } + } + + /** A policy with no correctness-critical dependency. */ + public static GrpcHealthPolicy standalone() { + return new GrpcHealthPolicy(Set.of(), false); + } + + /** A policy whose service cannot answer correctly without the named dependencies. */ + public static GrpcHealthPolicy dependingOn(Set dependencies) { + return new GrpcHealthPolicy(dependencies, false); + } + + /** + * Whether {@code dependencyName} being down should make this service unhealthy. + * + *

False for anything not declared critical, deliberately. + */ + public boolean affectsHealth(String dependencyName) { + return correctnessCriticalDependencies.contains(dependencyName); + } +} diff --git a/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcHealthState.java b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcHealthState.java new file mode 100644 index 00000000..81dda3a1 --- /dev/null +++ b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcHealthState.java @@ -0,0 +1,38 @@ +package dev.caskeleton.grpc.admin; + +/** + * What a health check reports about one service. + * + *

{@link #DRAINING} is the addition to the standard set, and it earns its place: a load balancer + * that sees {@code NOT_SERVING} has no way to tell "this instance is broken" from "this instance is + * finishing its work and going away", and the two call for opposite responses — one is an incident, + * the other is a rollout proceeding correctly. + */ +public enum GrpcHealthState { + /** Nothing is known yet. The state before the first check completes. */ + UNKNOWN(false), + /** Serving normally. */ + SERVING(true), + /** Not serving, and not deliberately. */ + NOT_SERVING(false), + /** The service is not registered here. */ + SERVICE_UNKNOWN(false), + /** Deliberately winding down. Send no new work; existing work is finishing. */ + DRAINING(false); + + private final boolean acceptsNewWork; + + GrpcHealthState(boolean acceptsNewWork) { + this.acceptsNewWork = acceptsNewWork; + } + + /** Whether new requests should be routed here. */ + public boolean acceptsNewWork() { + return acceptsNewWork; + } + + /** The standard gRPC health value a client sees, since DRAINING has no standard equivalent. */ + public GrpcHealthState standardEquivalent() { + return this == DRAINING ? NOT_SERVING : this; + } +} diff --git a/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcPlatformSnapshot.java b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcPlatformSnapshot.java new file mode 100644 index 00000000..d974f5be --- /dev/null +++ b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcPlatformSnapshot.java @@ -0,0 +1,71 @@ +package dev.caskeleton.grpc.admin; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * What the platform is running right now, as an operator sees it. + * + *

Hashes rather than contents for the policy catalog and the channel profiles. An operator's + * question is "is this instance running the configuration we released", and a hash answers it + * without publishing every deadline, every target and every retry rule to whoever can reach the + * admin endpoint. + * + *

Versioned and timestamped so two snapshots can be compared. An undated snapshot answers "what + * is running" and not "what changed", and the second is the question during an incident. + */ +public record GrpcPlatformSnapshot( + String snapshotVersion, + Instant capturedAt, + String schemaVersion, + String methodPolicyHash, + List registeredServices, + Map channelProfileHashes, + Map resolverAndLoadBalancerByChannel, + Map retryOwnerByChannel, + Map serviceHealth, + GrpcReflectionMode reflectionMode, + boolean draining) { + + /** Copies every collection and refuses an unversioned snapshot. */ + public GrpcPlatformSnapshot { + if (snapshotVersion == null || snapshotVersion.isBlank()) { + throw new IllegalArgumentException( + "a snapshot is versioned; an undated one answers what is running but not what changed"); + } + if (capturedAt == null) { + throw new IllegalArgumentException("a snapshot records when it was taken"); + } + if (schemaVersion == null || methodPolicyHash == null) { + throw new IllegalArgumentException("a snapshot carries the schema version and policy hash"); + } + if (registeredServices == null + || channelProfileHashes == null + || resolverAndLoadBalancerByChannel == null + || retryOwnerByChannel == null + || serviceHealth == null + || reflectionMode == null) { + throw new IllegalArgumentException("every snapshot section must be present"); + } + registeredServices = List.copyOf(registeredServices); + channelProfileHashes = Map.copyOf(channelProfileHashes); + resolverAndLoadBalancerByChannel = Map.copyOf(resolverAndLoadBalancerByChannel); + retryOwnerByChannel = Map.copyOf(retryOwnerByChannel); + serviceHealth = Map.copyOf(serviceHealth); + + List forbidden = GrpcAdminExposurePolicy.forbiddenFields(channelProfileHashes); + if (!forbidden.isEmpty()) { + throw new IllegalArgumentException( + "a platform snapshot must not carry " + forbidden + "; hashes and names only"); + } + } + + /** Whether this snapshot describes the same configuration as {@code other}. */ + public boolean sameConfigurationAs(GrpcPlatformSnapshot other) { + return other != null + && schemaVersion.equals(other.schemaVersion()) + && methodPolicyHash.equals(other.methodPolicyHash()) + && channelProfileHashes.equals(other.channelProfileHashes()); + } +} diff --git a/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcPlatformSnapshotService.java b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcPlatformSnapshotService.java new file mode 100644 index 00000000..a4d3a387 --- /dev/null +++ b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcPlatformSnapshotService.java @@ -0,0 +1,115 @@ +package dev.caskeleton.grpc.admin; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Produces platform snapshots for authorized readers, and compares one against a release manifest. + * + *

The drift comparison is what makes the snapshot operationally useful rather than decorative. + * An instance running a configuration the release did not ship is the state behind a whole class of + * incidents that are otherwise diagnosed by reading logs — a stale config map, a rollback that only + * half applied, an environment override nobody remembered. + */ +public final class GrpcPlatformSnapshotService { + + private final GrpcAdminExposurePolicy exposurePolicy; + private final GrpcServiceHealthRegistry health; + private final GrpcReflectionPolicy reflectionPolicy; + + /** Binds the service to its gates and the state it reports. */ + public GrpcPlatformSnapshotService( + GrpcAdminExposurePolicy exposurePolicy, + GrpcServiceHealthRegistry health, + GrpcReflectionPolicy reflectionPolicy) { + if (exposurePolicy == null || health == null || reflectionPolicy == null) { + throw new IllegalArgumentException( + "a snapshot service needs its policy, health and reflection"); + } + this.exposurePolicy = exposurePolicy; + this.health = health; + this.reflectionPolicy = reflectionPolicy; + } + + /** + * A snapshot, if the caller may read one. + * + * @return empty when the caller fails either gate. Empty rather than a redacted snapshot: a + * partial answer tells an unauthorized caller which services exist. + */ + public Optional capture( + String callerNetwork, + Set callerRoles, + String snapshotVersion, + String schemaVersion, + String methodPolicyHash, + List registeredServices, + Map channelProfileHashes, + Map resolverAndLoadBalancerByChannel, + Map retryOwnerByChannel, + Instant now) { + if (!exposurePolicy.mayRead(callerNetwork, callerRoles)) { + return Optional.empty(); + } + return Optional.of( + new GrpcPlatformSnapshot( + snapshotVersion, + now, + schemaVersion, + methodPolicyHash, + registeredServices, + channelProfileHashes, + resolverAndLoadBalancerByChannel, + retryOwnerByChannel, + health.snapshot(), + reflectionPolicy.mode(), + health.globalState() == GrpcHealthState.DRAINING)); + } + + /** + * How a running snapshot differs from what a release shipped. + * + * @return an empty list when the instance is running what was released + */ + public static List driftAgainstRelease( + GrpcPlatformSnapshot running, GrpcPlatformSnapshot released) { + if (running == null || released == null) { + throw new IllegalArgumentException("a drift comparison needs both snapshots"); + } + List drift = new java.util.ArrayList<>(); + if (!running.schemaVersion().equals(released.schemaVersion())) { + drift.add( + "schema version is " + + running.schemaVersion() + + " but the release shipped " + + released.schemaVersion()); + } + if (!running.methodPolicyHash().equals(released.methodPolicyHash())) { + drift.add("the method policy hash differs from the released one"); + } + released + .channelProfileHashes() + .forEach( + (channel, releasedHash) -> { + String runningHash = running.channelProfileHashes().get(channel); + if (runningHash == null) { + drift.add("channel profile '" + channel + "' is missing from the running instance"); + } else if (!runningHash.equals(releasedHash)) { + drift.add("channel profile '" + channel + "' differs from the released one"); + } + }); + running + .channelProfileHashes() + .keySet() + .forEach( + channel -> { + if (!released.channelProfileHashes().containsKey(channel)) { + drift.add("channel profile '" + channel + "' is not in the release"); + } + }); + return List.copyOf(drift); + } +} diff --git a/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcReflectionAccessDecision.java b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcReflectionAccessDecision.java new file mode 100644 index 00000000..fd0bb330 --- /dev/null +++ b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcReflectionAccessDecision.java @@ -0,0 +1,28 @@ +package dev.caskeleton.grpc.admin; + +/** + * Whether one caller may use reflection. + * + *

Carries a reason so a refused developer learns which condition failed. Reflection being off in + * production is a decision somebody made; a caller who cannot tell that from a misconfiguration + * files a bug against the wrong thing. + */ +public record GrpcReflectionAccessDecision(boolean allowed, String reason) { + + /** Requires a reason either way. */ + public GrpcReflectionAccessDecision { + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("a reflection decision explains itself"); + } + } + + /** Reflection is available to this caller. */ + public static GrpcReflectionAccessDecision allow(String reason) { + return new GrpcReflectionAccessDecision(true, reason); + } + + /** Reflection is not available to this caller. */ + public static GrpcReflectionAccessDecision deny(String reason) { + return new GrpcReflectionAccessDecision(false, reason); + } +} diff --git a/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcReflectionMode.java b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcReflectionMode.java new file mode 100644 index 00000000..190a17db --- /dev/null +++ b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcReflectionMode.java @@ -0,0 +1,32 @@ +package dev.caskeleton.grpc.admin; + +import dev.caskeleton.grpc.security.GrpcTlsProfile; + +/** + * Whether server reflection is exposed, and to whom. + * + *

Disabled in production by default. Reflection publishes the complete schema — every service, + * method, message and field — to anyone who can open a connection, which is a map of the attack + * surface handed out before authentication. It is genuinely useful in development, and the cost of + * leaving it on afterwards is that nobody notices it is on. + */ +public enum GrpcReflectionMode { + /** Anyone who can reach the port may enumerate the schema. */ + ENABLED, + /** Only a caller on the admin network holding an admin role. */ + ADMIN_ONLY, + /** Not registered at all. */ + DISABLED; + + /** The default for an environment. */ + public static GrpcReflectionMode defaultFor(GrpcTlsProfile.Environment environment) { + if (environment == null) { + throw new IllegalArgumentException("an environment is required"); + } + return switch (environment) { + case LOCAL, TEST -> ENABLED; + case DEV -> ADMIN_ONLY; + case STAGE, PROD -> DISABLED; + }; + } +} diff --git a/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcReflectionPolicy.java b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcReflectionPolicy.java new file mode 100644 index 00000000..f8124e77 --- /dev/null +++ b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcReflectionPolicy.java @@ -0,0 +1,74 @@ +package dev.caskeleton.grpc.admin; + +import dev.caskeleton.grpc.security.GrpcTlsProfile; +import java.util.Set; + +/** + * The reflection exposure decision, and the two conditions that gate it. + * + *

Network and role are both required in {@link GrpcReflectionMode#ADMIN_ONLY}, and requiring + * both is the point: a role check alone lets an admin credential leaked to the public network + * enumerate the schema, and a network check alone lets anyone who reaches the admin network do it. + * + *

Reflection visibility is separate from method authorization. A method that reflection reveals + * is not thereby callable, and a method reflection hides is not thereby protected. Conflating the + * two produces a schema treated as a secret and an authorization check nobody wrote. + */ +public record GrpcReflectionPolicy( + GrpcReflectionMode mode, Set adminNetworks, Set adminRoles) { + + /** Refuses an admin-only policy with nothing to check. */ + public GrpcReflectionPolicy { + if (mode == null) { + throw new IllegalArgumentException("a reflection policy names its mode"); + } + if (adminNetworks == null || adminRoles == null) { + throw new IllegalArgumentException("a reflection policy states both gates, even if empty"); + } + adminNetworks = Set.copyOf(adminNetworks); + adminRoles = Set.copyOf(adminRoles); + if (mode == GrpcReflectionMode.ADMIN_ONLY + && (adminNetworks.isEmpty() || adminRoles.isEmpty())) { + throw new IllegalArgumentException( + "ADMIN_ONLY requires both an admin network and an admin role; a role alone lets a leaked " + + "credential enumerate the schema from anywhere, and a network alone lets anyone who " + + "reaches it do the same"); + } + } + + /** The default policy for an environment. */ + public static GrpcReflectionPolicy defaultFor(GrpcTlsProfile.Environment environment) { + GrpcReflectionMode mode = GrpcReflectionMode.defaultFor(environment); + return mode == GrpcReflectionMode.ADMIN_ONLY + ? new GrpcReflectionPolicy(mode, Set.of("admin"), Set.of("ROLE_PLATFORM_ADMIN")) + : new GrpcReflectionPolicy(mode, Set.of(), Set.of()); + } + + /** Whether this caller may enumerate the schema. */ + public GrpcReflectionAccessDecision decide(String callerNetwork, Set callerRoles) { + return switch (mode) { + case DISABLED -> + GrpcReflectionAccessDecision.deny( + "reflection is disabled here; the schema is not published to callers"); + case ENABLED -> + GrpcReflectionAccessDecision.allow("reflection is enabled in this environment"); + case ADMIN_ONLY -> decideAdminOnly(callerNetwork, callerRoles); + }; + } + + private GrpcReflectionAccessDecision decideAdminOnly( + String callerNetwork, Set callerRoles) { + if (callerNetwork == null || !adminNetworks.contains(callerNetwork)) { + return GrpcReflectionAccessDecision.deny("the caller is not on an admin network"); + } + if (callerRoles == null || callerRoles.stream().noneMatch(adminRoles::contains)) { + return GrpcReflectionAccessDecision.deny("the caller holds no admin role"); + } + return GrpcReflectionAccessDecision.allow("admin network and admin role both satisfied"); + } + + /** Whether the reflection service should be registered on the server at all. */ + public boolean registerService() { + return mode != GrpcReflectionMode.DISABLED; + } +} diff --git a/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcServiceHealthRegistry.java b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcServiceHealthRegistry.java new file mode 100644 index 00000000..ee4ad401 --- /dev/null +++ b/src/grpc/grpc-admin/src/main/java/dev/caskeleton/grpc/admin/GrpcServiceHealthRegistry.java @@ -0,0 +1,154 @@ +package dev.caskeleton.grpc.admin; + +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Per-service health, plus the global answer derived from it. + * + *

Starts every registered service at {@link GrpcHealthState#UNKNOWN} rather than at {@code + * SERVING}. A registry that starts optimistic reports ready during startup, receives traffic before + * the first dependency check has run, and fails the requests that arrive in that window — the + * window being exactly the moment a rollout is shifting traffic onto the instance. + */ +public final class GrpcServiceHealthRegistry { + + /** The global health key, matching the standard gRPC health service convention. */ + public static final String GLOBAL_SERVICE = ""; + + private final ConcurrentMap states = new ConcurrentHashMap<>(); + private final ConcurrentMap dependencyHealth = new ConcurrentHashMap<>(); + private final GrpcHealthPolicy policy; + private volatile boolean draining; + + /** Binds a registry to its health policy. */ + public GrpcServiceHealthRegistry(GrpcHealthPolicy policy) { + if (policy == null) { + throw new IllegalArgumentException("a health registry needs a policy"); + } + this.policy = policy; + states.put(GLOBAL_SERVICE, GrpcHealthState.UNKNOWN); + } + + /** Registers a service, unknown until something says otherwise. */ + public void register(String serviceName) { + requireServiceName(serviceName); + states.putIfAbsent(serviceName, GrpcHealthState.UNKNOWN); + } + + /** + * Records that a service is serving. + * + *

Ignored while draining: a service that reports itself healthy after the drain has started + * would be routed traffic the instance has already promised not to take. + */ + public void markServing(String serviceName) { + requireServiceName(serviceName); + if (draining) { + return; + } + states.put(serviceName, GrpcHealthState.SERVING); + recomputeGlobal(); + } + + /** Records that a service is not serving. */ + public void markNotServing(String serviceName) { + requireServiceName(serviceName); + if (draining) { + return; + } + states.put(serviceName, GrpcHealthState.NOT_SERVING); + recomputeGlobal(); + } + + /** Records a dependency's health, which only matters when the policy says it does. */ + public void recordDependencyHealth(String dependencyName, boolean healthy) { + if (dependencyName == null || dependencyName.isBlank()) { + throw new IllegalArgumentException("a dependency needs a name"); + } + dependencyHealth.put(dependencyName, healthy); + if (!draining) { + recomputeGlobal(); + } + } + + /** + * Flips everything to draining. + * + *

Called first in a shutdown, before admission stops, so that a load balancer has a chance to + * stop sending before the server starts refusing. + */ + public void beginDraining() { + draining = true; + states.replaceAll((service, state) -> GrpcHealthState.DRAINING); + } + + /** The state of one service. */ + public GrpcHealthState stateOf(String serviceName) { + requireServiceName(serviceName); + return states.getOrDefault(serviceName, GrpcHealthState.SERVICE_UNKNOWN); + } + + /** The global state. */ + public GrpcHealthState globalState() { + return states.getOrDefault(GLOBAL_SERVICE, GrpcHealthState.UNKNOWN); + } + + /** Whether this instance should be routed new work. */ + public boolean ready() { + return globalState().acceptsNewWork(); + } + + /** Every registered service and its state. */ + public Map snapshot() { + return Map.copyOf(states); + } + + /** The critical dependencies currently reported unhealthy. */ + public Set unhealthyCriticalDependencies() { + Set unhealthy = new LinkedHashSet<>(); + dependencyHealth.forEach( + (dependency, healthy) -> { + if (!healthy && policy.affectsHealth(dependency)) { + unhealthy.add(dependency); + } + }); + return Set.copyOf(unhealthy); + } + + private void recomputeGlobal() { + if (!unhealthyCriticalDependencies().isEmpty()) { + states.put(GLOBAL_SERVICE, GrpcHealthState.NOT_SERVING); + return; + } + boolean anyServing = + states.entrySet().stream() + .anyMatch( + entry -> + !GLOBAL_SERVICE.equals(entry.getKey()) + && entry.getValue() == GrpcHealthState.SERVING); + boolean anyNotServing = + states.entrySet().stream() + .anyMatch( + entry -> + !GLOBAL_SERVICE.equals(entry.getKey()) + && entry.getValue() == GrpcHealthState.NOT_SERVING); + if (anyNotServing) { + states.put(GLOBAL_SERVICE, GrpcHealthState.NOT_SERVING); + } else if (anyServing) { + states.put(GLOBAL_SERVICE, GrpcHealthState.SERVING); + } else { + states.put(GLOBAL_SERVICE, GrpcHealthState.UNKNOWN); + } + } + + private static void requireServiceName(String serviceName) { + if (serviceName == null) { + throw new IllegalArgumentException( + "a service name is required; use GLOBAL_SERVICE for global"); + } + } +} diff --git a/src/grpc/grpc-admin/src/test/java/dev/caskeleton/grpc/admin/GrpcDrainCoordinatorTest.java b/src/grpc/grpc-admin/src/test/java/dev/caskeleton/grpc/admin/GrpcDrainCoordinatorTest.java new file mode 100644 index 00000000..de930093 --- /dev/null +++ b/src/grpc/grpc-admin/src/test/java/dev/caskeleton/grpc/admin/GrpcDrainCoordinatorTest.java @@ -0,0 +1,144 @@ +package dev.caskeleton.grpc.admin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.server.GrpcAdmissionController; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcDrainCoordinatorTest { + + private static final Instant START = Instant.parse("2026-08-30T10:00:00Z"); + private static final String DOCUMENTS = "hyeonworks.document.v1.DocumentService"; + + private final AtomicInteger unaryCalls = new AtomicInteger(); + private final AtomicInteger streams = new AtomicInteger(); + private final GrpcServiceHealthRegistry health = + new GrpcServiceHealthRegistry(GrpcHealthPolicy.standalone()); + + private GrpcDrainCoordinator coordinator(GrpcDrainPolicy policy) { + health.register(DOCUMENTS); + health.markServing(DOCUMENTS); + return new GrpcDrainCoordinator( + health, new GrpcAdmissionController(8, 8), policy, unaryCalls::get, streams::get); + } + + @Test + @DisplayName("readiness flips before anything is refused") + void readinessFlipsBeforeRefusal() { + GrpcDrainCoordinator coordinator = coordinator(GrpcDrainPolicy.stable()); + + coordinator.beginDrain(START); + + assertThat(health.ready()).isFalse(); + assertThat(health.stateOf(DOCUMENTS)).isEqualTo(GrpcHealthState.DRAINING); + assertThat(coordinator.admittingNewCalls()).isTrue(); + } + + @Test + @DisplayName("refusing before the drain has begun is an error") + void refusingBeforeDrainingIsAnError() { + GrpcDrainCoordinator coordinator = coordinator(GrpcDrainPolicy.stable()); + + assertThatThrownBy(coordinator::rejectNewAdmission) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("routing is still sending"); + } + + @Test + @DisplayName("the unary drain finishes when nothing is in flight") + void theUnaryDrainFinishesWhenIdle() { + GrpcDrainCoordinator coordinator = coordinator(GrpcDrainPolicy.stable()); + coordinator.beginDrain(START); + coordinator.rejectNewAdmission(); + unaryCalls.set(2); + + assertThat(coordinator.unaryDrainComplete(START.plusSeconds(1))).isFalse(); + unaryCalls.set(0); + assertThat(coordinator.unaryDrainComplete(START.plusSeconds(2))).isTrue(); + } + + @Test + @DisplayName("the unary drain gives up when its budget elapses") + void theUnaryDrainIsBounded() { + GrpcDrainCoordinator coordinator = coordinator(GrpcDrainPolicy.stable()); + coordinator.beginDrain(START); + unaryCalls.set(3); + + assertThat(coordinator.unaryDrainComplete(START.plusSeconds(14))).isFalse(); + assertThat(coordinator.unaryDrainComplete(START.plusSeconds(15))).isTrue(); + } + + @Test + @DisplayName("streams are signalled rather than waited for, and the count is recorded") + void streamsAreSignalledNotAwaited() { + GrpcDrainCoordinator coordinator = coordinator(GrpcDrainPolicy.stable()); + coordinator.beginDrain(START); + streams.set(5); + + coordinator.signalStreams(); + GrpcDrainResult result = coordinator.forceCancel(START.plusSeconds(20)); + + assertThat(result.signalledStreams()).isEqualTo(5); + assertThat(result.cancelledStreams()).isEqualTo(5); + assertThat(result.forcedAnything()).isTrue(); + } + + @Test + @DisplayName("a full drain runs every phase in order and reports what it achieved") + void aFullDrainRunsEveryPhase() { + GrpcDrainCoordinator coordinator = coordinator(GrpcDrainPolicy.stable()); + unaryCalls.set(4); + streams.set(2); + + coordinator.beginDrain(START); + coordinator.rejectNewAdmission(); + unaryCalls.set(0); + coordinator.recordCompletedUnaryCalls(4); + assertThat(coordinator.unaryDrainComplete(START.plusSeconds(3))).isTrue(); + coordinator.signalStreams(); + streams.set(0); + assertThat(coordinator.streamSignalComplete(START.plusSeconds(4))).isTrue(); + GrpcDrainResult result = coordinator.forceCancel(START.plusSeconds(5)); + + assertThat(result.phasesRun()) + .containsExactly( + GrpcDrainPhase.READINESS_FALSE, + GrpcDrainPhase.HEALTH_DRAINING, + GrpcDrainPhase.REJECT_NEW_ADMISSION, + GrpcDrainPhase.DRAIN_UNARY, + GrpcDrainPhase.SIGNAL_STREAMS, + GrpcDrainPhase.FORCE_CANCEL); + assertThat(result.complete()).isTrue(); + assertThat(result.completedUnaryCalls()).isEqualTo(4); + assertThat(result.cancelledUnaryCalls()).isZero(); + assertThat(result.forcedAnything()).isFalse(); + assertThat(result.elapsed()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + @DisplayName("beginning a drain twice does not restart the clock") + void beginningTwiceIsIdempotent() { + GrpcDrainCoordinator coordinator = coordinator(GrpcDrainPolicy.stable()); + coordinator.beginDrain(START); + coordinator.beginDrain(START.plusSeconds(30)); + unaryCalls.set(1); + + assertThat(coordinator.unaryDrainComplete(START.plusSeconds(15))).isTrue(); + } + + @Test + @DisplayName("drain budgets are separate and bounded in total") + void drainBudgetsAreSeparateAndBounded() { + GrpcDrainPolicy policy = GrpcDrainPolicy.stable(); + + assertThat(policy.totalBudget()).isEqualTo(Duration.ofSeconds(25)); + assertThatThrownBy(() -> new GrpcDrainPolicy(Duration.ZERO, Duration.ZERO, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cancels every in-flight call"); + } +} diff --git a/src/grpc/grpc-admin/src/test/java/dev/caskeleton/grpc/admin/GrpcPlatformSnapshotServiceTest.java b/src/grpc/grpc-admin/src/test/java/dev/caskeleton/grpc/admin/GrpcPlatformSnapshotServiceTest.java new file mode 100644 index 00000000..c512d491 --- /dev/null +++ b/src/grpc/grpc-admin/src/test/java/dev/caskeleton/grpc/admin/GrpcPlatformSnapshotServiceTest.java @@ -0,0 +1,172 @@ +package dev.caskeleton.grpc.admin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.security.GrpcTlsProfile; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcPlatformSnapshotServiceTest { + + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + private static final String DOCUMENTS = "hyeonworks.document.v1.DocumentService"; + + private final GrpcServiceHealthRegistry health = + new GrpcServiceHealthRegistry(GrpcHealthPolicy.standalone()); + + private GrpcPlatformSnapshotService service() { + health.register(DOCUMENTS); + health.markServing(DOCUMENTS); + return new GrpcPlatformSnapshotService( + GrpcAdminExposurePolicy.standard(), + health, + GrpcReflectionPolicy.defaultFor(GrpcTlsProfile.Environment.PROD)); + } + + private Optional capture( + String network, Set roles, String schemaVersion, Map channelHashes) { + return service() + .capture( + network, + roles, + "snapshot-1", + schemaVersion, + "sha256:policy-1", + List.of(DOCUMENTS), + channelHashes, + Map.of("documents-read", "dns/pick_first"), + Map.of("documents-read", "GRPC_PLATFORM"), + NOW); + } + + @Test + @DisplayName("a snapshot needs both an admin network and an admin role") + void bothGatesAreRequired() { + assertThat( + capture( + "admin", + Set.of("ROLE_PLATFORM_ADMIN"), + "1.4.0", + Map.of("documents-read", "sha256:channel-1"))) + .isPresent(); + assertThat( + capture( + "public", + Set.of("ROLE_PLATFORM_ADMIN"), + "1.4.0", + Map.of("documents-read", "sha256:channel-1"))) + .isEmpty(); + assertThat( + capture( + "admin", + Set.of("ROLE_USER"), + "1.4.0", + Map.of("documents-read", "sha256:channel-1"))) + .isEmpty(); + } + + @Test + @DisplayName("an admin policy with only one gate is refused") + void oneGateIsNotAnAdminSurface() { + assertThatThrownBy(() -> new GrpcAdminExposurePolicy(Set.of("admin"), Set.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("both a network and a role gate"); + } + + @Test + @DisplayName("a snapshot carries hashes and health, and never a credential field") + void aSnapshotCarriesNoSecrets() { + GrpcPlatformSnapshot snapshot = + capture( + "admin", + Set.of("ROLE_PLATFORM_ADMIN"), + "1.4.0", + Map.of("documents-read", "sha256:channel-1")) + .orElseThrow(); + + assertThat(snapshot.registeredServices()).containsExactly(DOCUMENTS); + assertThat(snapshot.serviceHealth()).containsEntry(DOCUMENTS, GrpcHealthState.SERVING); + assertThat(snapshot.reflectionMode()).isEqualTo(GrpcReflectionMode.DISABLED); + assertThat(snapshot.draining()).isFalse(); + } + + @Test + @DisplayName("a snapshot containing a credential-named field is refused outright") + void aCredentialFieldIsRefused() { + assertThatThrownBy( + () -> + capture( + "admin", + Set.of("ROLE_PLATFORM_ADMIN"), + "1.4.0", + Map.of("documents-read-client-secret", "value"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("hashes and names only"); + assertThat(GrpcAdminExposurePolicy.secretField("target-password")).isTrue(); + assertThat(GrpcAdminExposurePolicy.secretField("private_key_ref")).isTrue(); + assertThat(GrpcAdminExposurePolicy.secretField("documents-read")).isFalse(); + } + + @Test + @DisplayName("drift against the release manifest is reported field by field") + void driftAgainstReleaseIsReported() { + GrpcPlatformSnapshot running = + capture( + "admin", + Set.of("ROLE_PLATFORM_ADMIN"), + "1.5.0", + Map.of( + "documents-read", "sha256:channel-2", "documents-stream", "sha256:channel-9")) + .orElseThrow(); + GrpcPlatformSnapshot released = + capture( + "admin", + Set.of("ROLE_PLATFORM_ADMIN"), + "1.4.0", + Map.of("documents-read", "sha256:channel-1")) + .orElseThrow(); + + List drift = GrpcPlatformSnapshotService.driftAgainstRelease(running, released); + + assertThat(drift) + .anySatisfy(entry -> assertThat(entry).contains("schema version is 1.5.0")) + .anySatisfy(entry -> assertThat(entry).contains("documents-read")) + .anySatisfy(entry -> assertThat(entry).contains("documents-stream")); + } + + @Test + @DisplayName("an instance running the released configuration reports no drift") + void noDriftWhenRunningTheRelease() { + GrpcPlatformSnapshot snapshot = + capture( + "admin", + Set.of("ROLE_PLATFORM_ADMIN"), + "1.4.0", + Map.of("documents-read", "sha256:channel-1")) + .orElseThrow(); + + assertThat(GrpcPlatformSnapshotService.driftAgainstRelease(snapshot, snapshot)).isEmpty(); + assertThat(snapshot.sameConfigurationAs(snapshot)).isTrue(); + } + + @Test + @DisplayName("a snapshot must be versioned and timestamped") + void aSnapshotIsVersionedAndTimestamped() { + GrpcPlatformSnapshot snapshot = + capture( + "admin", + Set.of("ROLE_PLATFORM_ADMIN"), + "1.4.0", + Map.of("documents-read", "sha256:channel-1")) + .orElseThrow(); + + assertThat(snapshot.snapshotVersion()).isEqualTo("snapshot-1"); + assertThat(snapshot.capturedAt()).isEqualTo(NOW); + } +} diff --git a/src/grpc/grpc-admin/src/test/java/dev/caskeleton/grpc/admin/GrpcReflectionPolicyTest.java b/src/grpc/grpc-admin/src/test/java/dev/caskeleton/grpc/admin/GrpcReflectionPolicyTest.java new file mode 100644 index 00000000..c665745a --- /dev/null +++ b/src/grpc/grpc-admin/src/test/java/dev/caskeleton/grpc/admin/GrpcReflectionPolicyTest.java @@ -0,0 +1,83 @@ +package dev.caskeleton.grpc.admin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.security.GrpcTlsProfile; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcReflectionPolicyTest { + + @Test + @DisplayName("reflection is off in production and on locally by default") + void defaultsFollowTheEnvironment() { + assertThat(GrpcReflectionMode.defaultFor(GrpcTlsProfile.Environment.PROD)) + .isEqualTo(GrpcReflectionMode.DISABLED); + assertThat(GrpcReflectionMode.defaultFor(GrpcTlsProfile.Environment.STAGE)) + .isEqualTo(GrpcReflectionMode.DISABLED); + assertThat(GrpcReflectionMode.defaultFor(GrpcTlsProfile.Environment.DEV)) + .isEqualTo(GrpcReflectionMode.ADMIN_ONLY); + assertThat(GrpcReflectionMode.defaultFor(GrpcTlsProfile.Environment.LOCAL)) + .isEqualTo(GrpcReflectionMode.ENABLED); + assertThat(GrpcReflectionMode.defaultFor(GrpcTlsProfile.Environment.TEST)) + .isEqualTo(GrpcReflectionMode.ENABLED); + } + + @Test + @DisplayName("admin-only reflection requires both a network and a role") + void adminOnlyNeedsBothGates() { + GrpcReflectionPolicy policy = GrpcReflectionPolicy.defaultFor(GrpcTlsProfile.Environment.DEV); + + assertThat(policy.decide("admin", Set.of("ROLE_PLATFORM_ADMIN")).allowed()).isTrue(); + assertThat(policy.decide("public", Set.of("ROLE_PLATFORM_ADMIN")).allowed()).isFalse(); + assertThat(policy.decide("admin", Set.of("ROLE_USER")).allowed()).isFalse(); + assertThat(policy.decide(null, Set.of("ROLE_PLATFORM_ADMIN")).allowed()).isFalse(); + assertThat(policy.decide("admin", null).allowed()).isFalse(); + } + + @Test + @DisplayName("an admin-only policy with nothing to check is refused at construction") + void anAdminOnlyPolicyNeedsSomethingToCheck() { + assertThatThrownBy( + () -> + new GrpcReflectionPolicy(GrpcReflectionMode.ADMIN_ONLY, Set.of("admin"), Set.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("leaked credential"); + assertThatThrownBy( + () -> + new GrpcReflectionPolicy( + GrpcReflectionMode.ADMIN_ONLY, Set.of(), Set.of("ROLE_PLATFORM_ADMIN"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a disabled policy registers no service and explains its refusal") + void disabledRegistersNothing() { + GrpcReflectionPolicy policy = GrpcReflectionPolicy.defaultFor(GrpcTlsProfile.Environment.PROD); + + assertThat(policy.registerService()).isFalse(); + GrpcReflectionAccessDecision decision = policy.decide("admin", Set.of("ROLE_PLATFORM_ADMIN")); + assertThat(decision.allowed()).isFalse(); + assertThat(decision.reason()).contains("not published to callers"); + } + + @Test + @DisplayName("an enabled policy registers the service and allows any reachable caller") + void enabledRegistersAndAllows() { + GrpcReflectionPolicy policy = GrpcReflectionPolicy.defaultFor(GrpcTlsProfile.Environment.LOCAL); + + assertThat(policy.registerService()).isTrue(); + assertThat(policy.decide("anything", Set.of()).allowed()).isTrue(); + } + + @Test + @DisplayName("a decision always explains itself, allowed or not") + void everyDecisionExplainsItself() { + assertThatThrownBy(() -> GrpcReflectionAccessDecision.allow(" ")) + .isInstanceOf(IllegalArgumentException.class); + assertThat(GrpcReflectionAccessDecision.deny("no admin role").reason()) + .isEqualTo("no admin role"); + } +} diff --git a/src/grpc/grpc-admin/src/test/java/dev/caskeleton/grpc/admin/GrpcServiceHealthRegistryTest.java b/src/grpc/grpc-admin/src/test/java/dev/caskeleton/grpc/admin/GrpcServiceHealthRegistryTest.java new file mode 100644 index 00000000..d85f7c3a --- /dev/null +++ b/src/grpc/grpc-admin/src/test/java/dev/caskeleton/grpc/admin/GrpcServiceHealthRegistryTest.java @@ -0,0 +1,91 @@ +package dev.caskeleton.grpc.admin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcServiceHealthRegistryTest { + + private static final String DOCUMENTS = "hyeonworks.document.v1.DocumentService"; + + @Test + @DisplayName("an instance is not ready before its first check completes") + void readinessStartsFalse() { + GrpcServiceHealthRegistry registry = + new GrpcServiceHealthRegistry(GrpcHealthPolicy.standalone()); + registry.register(DOCUMENTS); + + assertThat(registry.globalState()).isEqualTo(GrpcHealthState.UNKNOWN); + assertThat(registry.ready()).isFalse(); + assertThat(registry.stateOf(DOCUMENTS)).isEqualTo(GrpcHealthState.UNKNOWN); + } + + @Test + @DisplayName("a policy that reports ready before the first check is refused") + void optimisticReadinessIsRefused() { + assertThatThrownBy(() -> new GrpcHealthPolicy(Set.of(), true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("before it can serve it"); + } + + @Test + @DisplayName("an unregistered service reports SERVICE_UNKNOWN") + void anUnregisteredServiceIsUnknown() { + GrpcServiceHealthRegistry registry = + new GrpcServiceHealthRegistry(GrpcHealthPolicy.standalone()); + + assertThat(registry.stateOf("hyeonworks.other.v1.OtherService")) + .isEqualTo(GrpcHealthState.SERVICE_UNKNOWN); + } + + @Test + @DisplayName("a serving service makes the instance ready") + void aServingServiceMakesTheInstanceReady() { + GrpcServiceHealthRegistry registry = + new GrpcServiceHealthRegistry(GrpcHealthPolicy.standalone()); + registry.register(DOCUMENTS); + + registry.markServing(DOCUMENTS); + + assertThat(registry.globalState()).isEqualTo(GrpcHealthState.SERVING); + assertThat(registry.ready()).isTrue(); + } + + @Test + @DisplayName("only a correctness-critical dependency affects health") + void onlyCriticalDependenciesAffectHealth() { + GrpcServiceHealthRegistry registry = + new GrpcServiceHealthRegistry(GrpcHealthPolicy.dependingOn(Set.of("documents-db"))); + registry.register(DOCUMENTS); + registry.markServing(DOCUMENTS); + + registry.recordDependencyHealth("analytics-sink", false); + assertThat(registry.ready()).isTrue(); + + registry.recordDependencyHealth("documents-db", false); + assertThat(registry.ready()).isFalse(); + assertThat(registry.unhealthyCriticalDependencies()).containsExactly("documents-db"); + } + + @Test + @DisplayName("draining is distinguishable from failing, and is not undone by a later check") + void drainingIsDistinguishableAndSticky() { + GrpcServiceHealthRegistry registry = + new GrpcServiceHealthRegistry(GrpcHealthPolicy.standalone()); + registry.register(DOCUMENTS); + registry.markServing(DOCUMENTS); + + registry.beginDraining(); + + assertThat(registry.stateOf(DOCUMENTS)).isEqualTo(GrpcHealthState.DRAINING); + assertThat(registry.ready()).isFalse(); + assertThat(GrpcHealthState.DRAINING.standardEquivalent()) + .isEqualTo(GrpcHealthState.NOT_SERVING); + + registry.markServing(DOCUMENTS); + assertThat(registry.stateOf(DOCUMENTS)).isEqualTo(GrpcHealthState.DRAINING); + } +} diff --git a/src/grpc/grpc-client/build.gradle b/src/grpc/grpc-client/build.gradle new file mode 100644 index 00000000..eb3931d3 --- /dev/null +++ b/src/grpc/grpc-client/build.gradle @@ -0,0 +1,17 @@ +apply plugin: 'java-library' + +// Client runtime: named channel profiles, channel runtime generations with drain, the typed stub +// factory that refuses to hand a raw Channel to application code, and client metadata/credentials. +dependencyManagement { + imports { + mavenBom "io.grpc:grpc-bom:${grpcVersion}" + } +} + +dependencies { + api project(':grpc:grpc-core-api') + api project(':grpc:grpc-policy') + + api "io.grpc:grpc-api:${grpcVersion}" + api "io.grpc:grpc-stub:${grpcVersion}" +} diff --git a/src/grpc/grpc-client/gradle.lockfile b/src/grpc/grpc-client/gradle.lockfile new file mode 100644 index 00000000..9369e831 --- /dev/null +++ b/src/grpc/grpc-client/gradle.lockfile @@ -0,0 +1,91 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty= diff --git a/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcCallCredentialProvider.java b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcCallCredentialProvider.java new file mode 100644 index 00000000..81565997 --- /dev/null +++ b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcCallCredentialProvider.java @@ -0,0 +1,32 @@ +package dev.caskeleton.grpc.client; + +import java.time.Instant; +import java.util.Optional; + +/** + * Supplies the credential for one call, at the moment of the call. + * + *

Per attempt, not per stub. A token captured when the stub was built is the token that was + * valid at startup: it expires while the process runs, a retry re-sends the expired one, and the + * failure looks like an authorization problem rather than a lifetime one. + * + *

The value returned is the header value only. Nothing here hands out the underlying key or + * refresh token, so a caller cannot cache one. + */ +@FunctionalInterface +public interface GrpcCallCredentialProvider { + + /** + * The credential to send, if the caller has one. + * + * @param at the moment of the call, so an expiring credential can be refreshed or refused + * @return empty when the call is legitimately anonymous. An expired credential must be refreshed + * or reported, never returned. + */ + Optional credentialFor(Instant at); + + /** A provider that never supplies a credential, for anonymous methods. */ + static GrpcCallCredentialProvider anonymous() { + return at -> Optional.empty(); + } +} diff --git a/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcChannelDrainPolicy.java b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcChannelDrainPolicy.java new file mode 100644 index 00000000..f40d9beb --- /dev/null +++ b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcChannelDrainPolicy.java @@ -0,0 +1,34 @@ +package dev.caskeleton.grpc.client; + +import java.time.Duration; + +/** + * How long a superseded channel generation keeps serving before its calls are cancelled. + * + *

Unary and stream drains are separated because their honest budgets differ by orders of + * magnitude. A unary call finishes inside its deadline; a subscription may legitimately have been + * open for an hour and will not finish at all, so its drain is a signal to reconnect rather than a + * wait. + */ +public record GrpcChannelDrainPolicy( + Duration unaryDrain, Duration streamDrain, boolean forceCancel) { + + /** Refuses a drain policy that would either hang or cut instantly. */ + public GrpcChannelDrainPolicy { + if (unaryDrain == null || streamDrain == null) { + throw new IllegalArgumentException("a drain policy needs both budgets"); + } + if (unaryDrain.isNegative() || streamDrain.isNegative()) { + throw new IllegalArgumentException("drain budgets must not be negative"); + } + if (!forceCancel && (unaryDrain.isZero() || streamDrain.isZero())) { + throw new IllegalArgumentException( + "a drain that neither waits nor force-cancels leaves the old generation alive forever"); + } + } + + /** The Stable default: wait briefly for unary calls, signal streams, then cancel. */ + public static GrpcChannelDrainPolicy stable() { + return new GrpcChannelDrainPolicy(Duration.ofSeconds(10), Duration.ofSeconds(30), true); + } +} diff --git a/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcChannelGeneration.java b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcChannelGeneration.java new file mode 100644 index 00000000..380f4818 --- /dev/null +++ b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcChannelGeneration.java @@ -0,0 +1,42 @@ +package dev.caskeleton.grpc.client; + +import dev.caskeleton.grpc.core.GrpcChannelProfileName; +import java.time.Instant; + +/** + * One version of a channel's configuration, numbered so that a swap is observable. + * + *

The number is what makes a rotation distinguishable from a restart. Without it, "the channel + * was reconfigured" and "some calls are still on the old configuration" are the same state, and the + * drain that separates them has nothing to key on. + */ +public record GrpcChannelGeneration( + GrpcChannelProfileName profileName, + long generation, + String configurationHash, + Instant createdAt) { + + /** Requires a positive generation and a configuration digest. */ + public GrpcChannelGeneration { + if (profileName == null) { + throw new IllegalArgumentException("a channel generation names its profile"); + } + if (generation < 1) { + throw new IllegalArgumentException("channel generations are 1-based; got " + generation); + } + if (configurationHash == null || configurationHash.isBlank()) { + throw new IllegalArgumentException( + "a channel generation carries a hash of the configuration it was built from"); + } + if (createdAt == null) { + throw new IllegalArgumentException("a channel generation needs a creation moment"); + } + } + + /** Whether {@code candidate} would legally succeed this generation. */ + public boolean supersededBy(GrpcChannelGeneration candidate) { + return candidate != null + && candidate.profileName().equals(profileName) + && candidate.generation() > generation; + } +} diff --git a/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcChannelProfileValidator.java b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcChannelProfileValidator.java new file mode 100644 index 00000000..d22f9ea8 --- /dev/null +++ b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcChannelProfileValidator.java @@ -0,0 +1,81 @@ +package dev.caskeleton.grpc.client; + +import dev.caskeleton.grpc.security.GrpcAuthenticationProfile; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Checks a set of channel profiles for the mistakes that do not announce themselves. + * + *

Two in particular. Round-robin over a target that resolves to one address is described in + * dashboards as client-side load balancing and is not; and two profiles pointing at the same target + * with the same settings are one channel with two names, which is the shape that appears when + * somebody wanted a different SLO and copied the profile instead. + */ +public final class GrpcChannelProfileValidator { + + private GrpcChannelProfileValidator() {} + + /** + * Every problem with {@code profiles}. + * + * @param resolvedAddressCounts how many addresses each profile's target actually resolves to, + * where that is known. A profile absent from the map is not checked for the round-robin + * mistake, because guessing would produce a false failure on a name nobody can resolve at + * startup. + */ + public static List violations( + List profiles, Map resolvedAddressCounts) { + if (profiles == null || resolvedAddressCounts == null) { + throw new IllegalArgumentException("validation needs the profiles and the resolved counts"); + } + List violations = new ArrayList<>(); + Map seenNames = new LinkedHashMap<>(); + + for (GrpcNamedChannelProfile profile : profiles) { + String profileName = profile.name().value(); + String previous = seenNames.putIfAbsent(profileName, profile.target().toString()); + if (previous != null) { + violations.add( + "channel profile '" + + profileName + + "' is declared twice, for '" + + previous + + "' and '" + + profile.target() + + "'"); + } + Integer addresses = resolvedAddressCounts.get(profileName); + if (addresses != null + && profile.loadBalancingPolicy().requiresMultipleAddresses() + && addresses <= 1) { + violations.add( + "channel profile '" + + profileName + + "' uses " + + profile.loadBalancingPolicy() + + " over a target that resolves to " + + addresses + + " address; every request goes to the same endpoint, so calling this " + + "client-side load balancing describes spreading that is not happening"); + } + } + return List.copyOf(violations); + } + + /** + * Whether {@code authentication} can be carried by {@code profile}. + * + * @throws IllegalStateException when the transport cannot carry the credential safely + */ + public static void requireCompatibleAuthentication( + GrpcNamedChannelProfile profile, GrpcAuthenticationProfile authentication) { + if (profile == null || authentication == null) { + throw new IllegalArgumentException( + "a compatibility check needs a profile and an authentication"); + } + authentication.requireCompatible(profile.tls()); + } +} diff --git a/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcChannelRuntime.java b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcChannelRuntime.java new file mode 100644 index 00000000..40a1741a --- /dev/null +++ b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcChannelRuntime.java @@ -0,0 +1,96 @@ +package dev.caskeleton.grpc.client; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * A live channel generation and the calls currently on it. + * + *

Counts in-flight unary calls and open streams separately, because a drain treats them + * differently: unary calls are waited for, streams are signalled. A single counter would make the + * drain either cut a stream that could have finished or wait an hour for one that never will. + * + *

The underlying {@code ManagedChannel} is deliberately not exposed. Handing it out is how + * application code ends up building a stub with no policy attached. + */ +public final class GrpcChannelRuntime { + + private final GrpcChannelGeneration generation; + private final AtomicInteger inFlightUnaryCalls = new AtomicInteger(); + private final AtomicInteger openStreams = new AtomicInteger(); + private volatile boolean draining; + + /** Wraps one generation. */ + public GrpcChannelRuntime(GrpcChannelGeneration generation) { + if (generation == null) { + throw new IllegalArgumentException("a channel runtime needs its generation"); + } + this.generation = generation; + } + + /** The generation this runtime serves. */ + public GrpcChannelGeneration generation() { + return generation; + } + + /** + * Registers a starting unary call. + * + * @return false when the runtime is draining, in which case the caller uses the current + * generation + */ + public boolean startUnaryCall() { + if (draining) { + return false; + } + inFlightUnaryCalls.incrementAndGet(); + return true; + } + + /** Registers a finished unary call. */ + public void finishUnaryCall() { + if (inFlightUnaryCalls.get() > 0) { + inFlightUnaryCalls.decrementAndGet(); + } + } + + /** Registers an opening stream. */ + public boolean openStream() { + if (draining) { + return false; + } + openStreams.incrementAndGet(); + return true; + } + + /** Registers a closed stream. */ + public void closeStream() { + if (openStreams.get() > 0) { + openStreams.decrementAndGet(); + } + } + + /** Stops admitting new work onto this generation. */ + public void beginDrain() { + draining = true; + } + + /** Whether this runtime is draining. */ + public boolean draining() { + return draining; + } + + /** How many unary calls are still running. */ + public int inFlightUnaryCalls() { + return inFlightUnaryCalls.get(); + } + + /** How many streams are still open. */ + public int openStreams() { + return openStreams.get(); + } + + /** Whether everything on this generation has finished. */ + public boolean quiescent() { + return inFlightUnaryCalls.get() == 0 && openStreams.get() == 0; + } +} diff --git a/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcChannelRuntimeRegistry.java b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcChannelRuntimeRegistry.java new file mode 100644 index 00000000..b25dec2d --- /dev/null +++ b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcChannelRuntimeRegistry.java @@ -0,0 +1,137 @@ +package dev.caskeleton.grpc.client; + +import dev.caskeleton.grpc.core.GrpcChannelProfileName; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Holds one live runtime per channel profile and swaps it without dropping work. + * + *

Two properties, and the first is the boring one that matters most: a channel is created once + * and reused. Creating one per request is a mistake that works — every call succeeds — while + * spending a TCP handshake, a TLS handshake and an HTTP/2 setup on each one, and it is usually + * found by a connection count rather than by a failure. + * + *

The second is prepare-then-swap. The new runtime exists before the pointer moves, so no call + * ever finds nothing there; the old one drains rather than being closed under its in-flight work. + */ +public final class GrpcChannelRuntimeRegistry { + + private final ConcurrentMap> current = + new ConcurrentHashMap<>(); + private final ConcurrentMap> draining = + new ConcurrentHashMap<>(); + private final GrpcChannelDrainPolicy drainPolicy; + + /** Binds a registry to the drain policy its swaps use. */ + public GrpcChannelRuntimeRegistry(GrpcChannelDrainPolicy drainPolicy) { + if (drainPolicy == null) { + throw new IllegalArgumentException("a channel registry needs a drain policy"); + } + this.drainPolicy = drainPolicy; + } + + /** Installs the first generation for a profile. */ + public GrpcChannelRuntime install(GrpcChannelGeneration generation) { + if (generation == null) { + throw new IllegalArgumentException("a generation is required"); + } + GrpcChannelRuntime runtime = new GrpcChannelRuntime(generation); + AtomicReference holder = + current.computeIfAbsent(generation.profileName(), key -> new AtomicReference<>()); + if (!holder.compareAndSet(null, runtime)) { + throw new IllegalStateException( + "channel profile '" + + generation.profileName().value() + + "' already has a runtime; use rotate()"); + } + return runtime; + } + + /** + * The runtime new calls should use. + * + * @throws IllegalStateException when nothing is installed, because returning empty would make + * "not configured" indistinguishable from "configured and unreachable" + */ + public GrpcChannelRuntime require(GrpcChannelProfileName profileName) { + AtomicReference holder = current.get(profileName); + GrpcChannelRuntime runtime = holder == null ? null : holder.get(); + if (runtime == null) { + throw new IllegalStateException( + "no channel runtime is installed for profile '" + profileName.value() + "'"); + } + return runtime; + } + + /** The runtime for a profile, if one is installed. */ + public Optional find(GrpcChannelProfileName profileName) { + AtomicReference holder = current.get(profileName); + return Optional.ofNullable(holder == null ? null : holder.get()); + } + + /** + * Prepares the next generation, swaps it in, and starts draining the previous one. + * + * @throws IllegalArgumentException when {@code next} does not supersede what is installed + */ + public GrpcChannelRuntime rotate(GrpcChannelGeneration next, Instant now) { + if (next == null || now == null) { + throw new IllegalArgumentException("a rotation needs a generation and a moment"); + } + AtomicReference holder = current.get(next.profileName()); + if (holder == null || holder.get() == null) { + return install(next); + } + GrpcChannelRuntime previous = holder.get(); + if (!previous.generation().supersededBy(next)) { + throw new IllegalArgumentException( + "generation " + + next.generation() + + " does not supersede the installed generation " + + previous.generation().generation() + + " for profile '" + + next.profileName().value() + + "'"); + } + GrpcChannelRuntime replacement = new GrpcChannelRuntime(next); + holder.set(replacement); + previous.beginDrain(); + draining + .computeIfAbsent( + next.profileName(), key -> java.util.Collections.synchronizedList(new ArrayList<>())) + .add(previous); + return replacement; + } + + /** The generations still draining for a profile. */ + public List draining(GrpcChannelProfileName profileName) { + return List.copyOf(draining.getOrDefault(profileName, List.of())); + } + + /** + * Forgets every draining runtime that has gone quiet. + * + * @return how many were retired + */ + public int retireQuiescent(GrpcChannelProfileName profileName) { + List runtimes = draining.get(profileName); + if (runtimes == null) { + return 0; + } + List quiescent = + runtimes.stream().filter(GrpcChannelRuntime::quiescent).toList(); + runtimes.removeAll(quiescent); + return quiescent.size(); + } + + /** The drain policy in force. */ + public GrpcChannelDrainPolicy drainPolicy() { + return drainPolicy; + } +} diff --git a/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcClientCallContext.java b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcClientCallContext.java new file mode 100644 index 00000000..49e8b009 --- /dev/null +++ b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcClientCallContext.java @@ -0,0 +1,67 @@ +package dev.caskeleton.grpc.client; + +import dev.caskeleton.grpc.context.GrpcMetadataKey; +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +/** + * Everything one outbound call carries, assembled once and then immutable. + * + *

The credential is resolved here, at call time, and held as the header value rather than as the + * provider — so the value that goes on the wire is the one that was valid when the attempt started, + * and a retry assembles a new context rather than replaying an old header. + */ +public record GrpcClientCallContext( + GrpcMethodName method, + GrpcChannelRuntime channel, + GrpcDeadlineBudget deadline, + Map metadata, + Optional credential) { + + /** Copies the metadata map. */ + public GrpcClientCallContext { + if (method == null + || channel == null + || deadline == null + || metadata == null + || credential == null) { + throw new IllegalArgumentException("a client call context needs every part"); + } + metadata = Map.copyOf(metadata); + } + + /** + * Assembles a call context, resolving the credential at {@code at}. + * + * @throws IllegalStateException when the deadline is already spent, checked before anything is + * sent rather than after the server has done the work + */ + public static GrpcClientCallContext assemble( + GrpcMethodName method, + GrpcChannelRuntime channel, + GrpcDeadlineBudget deadline, + GrpcClientMetadataPolicy metadataPolicy, + Map proposedMetadata, + GrpcCallCredentialProvider credentialProvider, + Instant at) { + if (metadataPolicy == null || credentialProvider == null || at == null) { + throw new IllegalArgumentException( + "assembling a call needs a metadata policy, a credential provider and a moment"); + } + if (deadline == null || deadline.expired()) { + throw new IllegalStateException( + "refusing to start '" + + (method == null ? "an unnamed method" : method.canonical()) + + "': its deadline is already spent"); + } + return new GrpcClientCallContext( + method, + channel, + deadline, + metadataPolicy.materialize(proposedMetadata), + credentialProvider.credentialFor(at)); + } +} diff --git a/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcClientMetadataPolicy.java b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcClientMetadataPolicy.java new file mode 100644 index 00000000..e97561ae --- /dev/null +++ b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcClientMetadataPolicy.java @@ -0,0 +1,87 @@ +package dev.caskeleton.grpc.client; + +import dev.caskeleton.grpc.context.GrpcMetadataBudget; +import dev.caskeleton.grpc.context.GrpcMetadataKey; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * What an outbound call is allowed to carry, and what it may not. + * + *

Two allowlists rather than one, because a trust boundary changes the answer. Tenant and actor + * metadata is meaningful to a service inside the same trust domain and is an unverified assertion + * to one outside it; sending it across the boundary invites the receiver to trust it. So a + * cross-boundary channel gets the smaller list, and the distinction is a property of the profile + * rather than a decision at each call site. + * + *

Authorization is never on either list. It comes from a {@link GrpcCallCredentialProvider}, so + * a caller cannot set it as an ordinary header and bypass rotation. + */ +public record GrpcClientMetadataPolicy( + Set sameTrustDomainKeys, + Set crossTrustBoundaryKeys, + GrpcMetadataBudget budget, + boolean crossesTrustBoundary) { + + private static final String AUTHORIZATION = "authorization"; + + /** Refuses an authorization key on either list, and a cross-boundary list that is not smaller. */ + public GrpcClientMetadataPolicy { + if (sameTrustDomainKeys == null || crossTrustBoundaryKeys == null || budget == null) { + throw new IllegalArgumentException("a metadata policy needs both allowlists and a budget"); + } + sameTrustDomainKeys = Set.copyOf(sameTrustDomainKeys); + crossTrustBoundaryKeys = Set.copyOf(crossTrustBoundaryKeys); + for (GrpcMetadataKey key : sameTrustDomainKeys) { + requireNotAuthorization(key); + } + for (GrpcMetadataKey key : crossTrustBoundaryKeys) { + requireNotAuthorization(key); + if (!sameTrustDomainKeys.contains(key)) { + throw new IllegalArgumentException( + "cross-boundary key '" + + key.name() + + "' is not on the same-domain list; the cross-boundary allowlist is a subset"); + } + } + } + + private static void requireNotAuthorization(GrpcMetadataKey key) { + if (AUTHORIZATION.equals(key.name())) { + throw new IllegalArgumentException( + "'authorization' is supplied per call by a credential provider, not set as metadata; a " + + "header set by the application is a header that survives a rotation"); + } + } + + /** The keys this policy will send on this channel. */ + public Set effectiveAllowlist() { + return crossesTrustBoundary ? crossTrustBoundaryKeys : sameTrustDomainKeys; + } + + /** + * The metadata that will actually be sent, with anything outside the allowlist dropped. + * + *

Dropped rather than refused, unlike the inbound direction: an outbound call that fails + * because a caller attached an unknown correlation header is a worse outcome than one that sends + * a smaller set. The budget is still enforced. + * + * @throws IllegalArgumentException when the surviving set exceeds the budget + */ + public Map materialize(Map proposed) { + if (proposed == null) { + throw new IllegalArgumentException("proposed metadata must not be null"); + } + Set allowed = effectiveAllowlist(); + Map accepted = new LinkedHashMap<>(); + proposed.forEach( + (key, value) -> { + if (allowed.contains(key) && value != null) { + accepted.put(key, value); + } + }); + budget.check(accepted); + return Map.copyOf(accepted); + } +} diff --git a/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcLoadBalancingPolicy.java b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcLoadBalancingPolicy.java new file mode 100644 index 00000000..dce0b51f --- /dev/null +++ b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcLoadBalancingPolicy.java @@ -0,0 +1,35 @@ +package dev.caskeleton.grpc.client; + +/** + * The load-balancing policies the Stable platform supports. + * + *

Two, and the choice follows from what the resolver returns rather than from preference. {@link + * #ROUND_ROBIN} over a resolver that yields one virtual address balances nothing: every request + * goes to the same VIP and the spreading happens, or does not, inside the infrastructure. That + * combination is the most common way a team believes it has client-side load balancing and does + * not. + */ +public enum GrpcLoadBalancingPolicy { + /** Use the first working address. Correct for a single VIP. */ + PICK_FIRST("pick_first", false), + /** Rotate across every resolved address. Requires a resolver that returns several. */ + ROUND_ROBIN("round_robin", true); + + private final String serviceConfigName; + private final boolean requiresMultipleAddresses; + + GrpcLoadBalancingPolicy(String serviceConfigName, boolean requiresMultipleAddresses) { + this.serviceConfigName = serviceConfigName; + this.requiresMultipleAddresses = requiresMultipleAddresses; + } + + /** The name as it appears in a gRPC service config. */ + public String serviceConfigName() { + return serviceConfigName; + } + + /** Whether this policy is meaningless unless the resolver returns more than one address. */ + public boolean requiresMultipleAddresses() { + return requiresMultipleAddresses; + } +} diff --git a/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcNamedChannelProfile.java b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcNamedChannelProfile.java new file mode 100644 index 00000000..959d2f4d --- /dev/null +++ b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcNamedChannelProfile.java @@ -0,0 +1,100 @@ +package dev.caskeleton.grpc.client; + +import dev.caskeleton.grpc.core.GrpcChannelProfileName; +import dev.caskeleton.grpc.resilience.GrpcRetryOwner; +import dev.caskeleton.grpc.security.GrpcTlsProfile; +import java.net.URI; +import java.time.Duration; +import java.util.Set; + +/** + * One logical client channel: where it points, how it is secured, who retries on it, and what it + * will carry. + * + *

Named for the traffic rather than the backend, because the same backend reached with two + * different service levels is two channels. A long-lived subscription and a user-facing read share + * a host and share almost nothing else: different deadlines, different retry ownership, different + * keepalive, and a connection pool whose saturation by one starves the other. + */ +public record GrpcNamedChannelProfile( + GrpcChannelProfileName name, + URI target, + GrpcLoadBalancingPolicy loadBalancingPolicy, + GrpcRetryOwner retryOwner, + GrpcTlsProfile tls, + long maxInboundMessageBytes, + int maxInboundMetadataBytes, + Duration keepAliveTime, + boolean waitForReadyDefault) { + + /** The resolver schemes the Stable platform accepts in a target. */ + private static final Set STABLE_SCHEMES = Set.of("dns", "static", "unix"); + + /** Refuses a target or a combination the Stable platform does not support. */ + public GrpcNamedChannelProfile { + if (name == null + || target == null + || loadBalancingPolicy == null + || retryOwner == null + || tls == null) { + throw new IllegalArgumentException("a channel profile needs every part named"); + } + String scheme = target.getScheme(); + if (scheme == null || scheme.isBlank()) { + throw new IllegalArgumentException( + "a channel target carries its resolver scheme, e.g. 'dns:///documents:9090'; got '" + + target + + "'"); + } + if (!STABLE_SCHEMES.contains(scheme)) { + throw new IllegalArgumentException( + "resolver scheme '" + + scheme + + "' is not Stable; xDS and custom resolvers are Advanced capabilities behind a " + + "feature flag. Stable schemes are " + + STABLE_SCHEMES.stream().sorted().toList()); + } + if (maxInboundMessageBytes < 1L || maxInboundMetadataBytes < 1) { + throw new IllegalArgumentException("message and metadata bounds must be positive"); + } + if (keepAliveTime == null || keepAliveTime.isZero() || keepAliveTime.isNegative()) { + throw new IllegalArgumentException( + "a keep-alive interval must be positive; without one an idle connection is discovered " + + "dead by the next request rather than by a probe"); + } + if (waitForReadyDefault && retryOwner == GrpcRetryOwner.NONE) { + throw new IllegalArgumentException( + "wait-for-ready with no retry owner queues a call that nobody will re-attempt"); + } + } + + /** A read-path channel over DNS with a single VIP. */ + public static GrpcNamedChannelProfile virtualIp( + String name, URI target, GrpcTlsProfile tls, GrpcRetryOwner retryOwner) { + return new GrpcNamedChannelProfile( + new GrpcChannelProfileName(name), + target, + GrpcLoadBalancingPolicy.PICK_FIRST, + retryOwner, + tls, + 1024L * 1024L, + 8192, + Duration.ofSeconds(30), + false); + } + + /** A channel over a headless DNS record with several addresses. */ + public static GrpcNamedChannelProfile headless( + String name, URI target, GrpcTlsProfile tls, GrpcRetryOwner retryOwner) { + return new GrpcNamedChannelProfile( + new GrpcChannelProfileName(name), + target, + GrpcLoadBalancingPolicy.ROUND_ROBIN, + retryOwner, + tls, + 1024L * 1024L, + 8192, + Duration.ofSeconds(30), + false); + } +} diff --git a/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcStubDescriptor.java b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcStubDescriptor.java new file mode 100644 index 00000000..567da3f4 --- /dev/null +++ b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcStubDescriptor.java @@ -0,0 +1,33 @@ +package dev.caskeleton.grpc.client; + +import dev.caskeleton.grpc.core.GrpcChannelProfileName; + +/** + * Which generated stub type this factory may build, on which channel, in which calling style. + * + *

An allowlist entry rather than a lookup. A factory that will build any type it is handed is a + * factory through which an unregistered service gets a channel with nobody's policy on it, and the + * first time anyone notices is when that call has no deadline. + * + * @param the generated stub type + */ +public record GrpcStubDescriptor( + Class stubType, GrpcChannelProfileName channelProfile, Style style) { + + /** How the caller interacts with the stub. */ + public enum Style { + /** Blocks until the response arrives. */ + BLOCKING, + /** Returns a future. */ + FUTURE, + /** Delivers through a stream observer. */ + ASYNC + } + + /** Requires all three parts. */ + public GrpcStubDescriptor { + if (stubType == null || channelProfile == null || style == null) { + throw new IllegalArgumentException("a stub descriptor names its type, channel and style"); + } + } +} diff --git a/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcStubPolicyApplier.java b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcStubPolicyApplier.java new file mode 100644 index 00000000..8ca51fa2 --- /dev/null +++ b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcStubPolicyApplier.java @@ -0,0 +1,68 @@ +package dev.caskeleton.grpc.client; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget; +import dev.caskeleton.grpc.deadline.GrpcDeadlineCalculator; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import dev.caskeleton.grpc.policy.GrpcMethodPolicyCatalog; +import dev.caskeleton.grpc.resilience.GrpcWaitForReadyDecision; +import dev.caskeleton.grpc.resilience.GrpcWaitForReadyProfile; +import dev.caskeleton.grpc.resilience.GrpcWaitForReadyValidator; +import java.time.Duration; + +/** + * Applies the method policy immediately before a call goes out. + * + *

At call time rather than at stub construction, because the two inputs that matter are only + * known then: how much of the caller's deadline is left, and whether the channel is ready. A stub + * configured once with a fixed deadline gives every call the same budget regardless of how much of + * it the caller has already spent. + */ +public final class GrpcStubPolicyApplier { + + private final GrpcMethodPolicyCatalog catalog; + private final GrpcWaitForReadyProfile waitForReadyProfile; + + /** Binds an applier to the catalog it enforces and the channel's wait-for-ready profile. */ + public GrpcStubPolicyApplier( + GrpcMethodPolicyCatalog catalog, GrpcWaitForReadyProfile waitForReadyProfile) { + if (catalog == null || waitForReadyProfile == null) { + throw new IllegalArgumentException( + "a policy applier needs a catalog and a wait-for-ready profile"); + } + this.catalog = catalog; + this.waitForReadyProfile = waitForReadyProfile; + } + + /** What the applier decided for one call. */ + public record CallPolicy( + GrpcMethodPolicy method, GrpcDeadlineBudget deadline, GrpcWaitForReadyDecision waitForReady) { + + /** Requires all three. */ + public CallPolicy { + if (method == null || deadline == null || waitForReady == null) { + throw new IllegalArgumentException( + "a call policy carries the method, deadline and queueing"); + } + } + } + + /** + * The effective policy for one call. + * + * @param parentRemaining what the caller's own deadline leaves, or null when there is none + * @throws IllegalStateException when the method has no registered policy + */ + public CallPolicy applyTo(GrpcMethodName method, Duration parentRemaining) { + GrpcMethodPolicy policy = catalog.require(method); + GrpcDeadlineBudget deadline = GrpcDeadlineCalculator.forInboundCall(policy, parentRemaining); + GrpcWaitForReadyDecision waitForReady = + GrpcWaitForReadyValidator.decide(policy, waitForReadyProfile, deadline); + return new CallPolicy(policy, deadline, waitForReady); + } + + /** The catalog this applier enforces. */ + public GrpcMethodPolicyCatalog catalog() { + return catalog; + } +} diff --git a/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcTypedStubFactory.java b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcTypedStubFactory.java new file mode 100644 index 00000000..882cb946 --- /dev/null +++ b/src/grpc/grpc-client/src/main/java/dev/caskeleton/grpc/client/GrpcTypedStubFactory.java @@ -0,0 +1,119 @@ +package dev.caskeleton.grpc.client; + +import dev.caskeleton.grpc.core.GrpcChannelProfileName; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +/** + * Builds registered generated stubs, and nothing else. + * + *

Two refusals define it. It will not build a stub type nobody registered, so a service cannot + * acquire a channel without a policy; and it never returns a {@code Channel} or a builder, so + * application code has no way to construct one itself. Both are what make the raw-API import rule + * enforceable rather than merely stated: there is nothing to reach for. + */ +public final class GrpcTypedStubFactory { + + private final GrpcChannelRuntimeRegistry registry; + private final Map, GrpcStubDescriptor> descriptors = new LinkedHashMap<>(); + private final Map, Function> builders = new LinkedHashMap<>(); + private final GrpcStubPolicyApplier policyApplier; + + private GrpcTypedStubFactory( + GrpcChannelRuntimeRegistry registry, GrpcStubPolicyApplier policyApplier) { + this.registry = registry; + this.policyApplier = policyApplier; + } + + /** A builder bound to the channel registry and the policy applier. */ + public static Builder builder( + GrpcChannelRuntimeRegistry registry, GrpcStubPolicyApplier policyApplier) { + if (registry == null || policyApplier == null) { + throw new IllegalArgumentException( + "a stub factory needs a channel registry and a policy applier"); + } + return new Builder(registry, policyApplier); + } + + /** + * A stub for {@code stubType}, on the channel its descriptor names. + * + * @throws IllegalArgumentException when the type is not registered + */ + public S stubFor(Class stubType) { + GrpcStubDescriptor descriptor = descriptors.get(stubType); + if (descriptor == null) { + throw new IllegalArgumentException( + "stub type '" + + (stubType == null ? "null" : stubType.getName()) + + "' is not registered; an unregistered stub would get a channel with no method " + + "policy, no deadline and no credential provider"); + } + GrpcChannelRuntime runtime = registry.require(descriptor.channelProfile()); + return stubType.cast(builders.get(stubType).apply(runtime)); + } + + /** The descriptor for a registered stub type. */ + public GrpcStubDescriptor descriptorFor(Class stubType) { + GrpcStubDescriptor descriptor = descriptors.get(stubType); + if (descriptor == null) { + throw new IllegalArgumentException("stub type is not registered: " + stubType); + } + return descriptor; + } + + /** Every registered stub type. */ + public Set> registeredStubTypes() { + return Set.copyOf(descriptors.keySet()); + } + + /** The policy applier every call goes through. */ + public GrpcStubPolicyApplier policyApplier() { + return policyApplier; + } + + /** Registers stub types before the factory is used. */ + public static final class Builder { + + private final GrpcTypedStubFactory factory; + + private Builder(GrpcChannelRuntimeRegistry registry, GrpcStubPolicyApplier policyApplier) { + this.factory = new GrpcTypedStubFactory(registry, policyApplier); + } + + /** + * Registers one generated stub type. + * + * @param stubBuilder how to build it from a channel runtime. Takes the runtime rather than a + * raw channel, so a registration cannot smuggle one out. + */ + public Builder register( + GrpcStubDescriptor descriptor, Function stubBuilder) { + if (descriptor == null || stubBuilder == null) { + throw new IllegalArgumentException("a registration needs a descriptor and a builder"); + } + if (factory.descriptors.putIfAbsent(descriptor.stubType(), descriptor) != null) { + throw new IllegalArgumentException( + "stub type '" + descriptor.stubType().getName() + "' is registered twice"); + } + factory.builders.put(descriptor.stubType(), stubBuilder); + return this; + } + + /** Builds the factory. */ + public GrpcTypedStubFactory build() { + if (factory.descriptors.isEmpty()) { + throw new IllegalStateException( + "a stub factory with no registered types can build nothing and refuses everything"); + } + return factory; + } + } + + /** The channel profile a registered stub type uses. */ + public GrpcChannelProfileName channelProfileFor(Class stubType) { + return descriptorFor(stubType).channelProfile(); + } +} diff --git a/src/grpc/grpc-client/src/test/java/dev/caskeleton/grpc/client/GrpcChannelRuntimeRegistryTest.java b/src/grpc/grpc-client/src/test/java/dev/caskeleton/grpc/client/GrpcChannelRuntimeRegistryTest.java new file mode 100644 index 00000000..796e4a1c --- /dev/null +++ b/src/grpc/grpc-client/src/test/java/dev/caskeleton/grpc/client/GrpcChannelRuntimeRegistryTest.java @@ -0,0 +1,128 @@ +package dev.caskeleton.grpc.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcChannelProfileName; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcChannelRuntimeRegistryTest { + + private static final GrpcChannelProfileName PROFILE = + new GrpcChannelProfileName("documents-read"); + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + + private static GrpcChannelGeneration generation(long number) { + return new GrpcChannelGeneration(PROFILE, number, "sha256:config-" + number, NOW); + } + + private final GrpcChannelRuntimeRegistry registry = + new GrpcChannelRuntimeRegistry(GrpcChannelDrainPolicy.stable()); + + @Test + @DisplayName("a channel is created once and reused, not built per request") + void aChannelIsReusedAcrossCalls() { + GrpcChannelRuntime installed = registry.install(generation(1L)); + + assertThat(registry.require(PROFILE)).isSameAs(installed); + assertThat(registry.require(PROFILE)).isSameAs(installed); + assertThat(registry.find(PROFILE)).contains(installed); + } + + @Test + @DisplayName("installing twice is refused; rotation is the way to replace a generation") + void installingTwiceIsRefused() { + registry.install(generation(1L)); + + assertThatThrownBy(() -> registry.install(generation(2L))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("use rotate()"); + } + + @Test + @DisplayName("an unconfigured profile fails loudly rather than returning nothing") + void anUnconfiguredProfileFailsLoudly() { + assertThatThrownBy(() -> registry.require(PROFILE)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("no channel runtime is installed"); + assertThat(registry.find(PROFILE)).isEmpty(); + } + + @Test + @DisplayName("a rotation swaps the pointer and starts draining the previous generation") + void rotationSwapsThenDrains() { + GrpcChannelRuntime first = registry.install(generation(1L)); + first.startUnaryCall(); + + GrpcChannelRuntime second = registry.rotate(generation(2L), NOW); + + assertThat(registry.require(PROFILE)).isSameAs(second); + assertThat(first.draining()).isTrue(); + assertThat(registry.draining(PROFILE)).containsExactly(first); + assertThat(first.inFlightUnaryCalls()).isEqualTo(1); + } + + @Test + @DisplayName("a draining generation admits no new work, and existing work finishes") + void aDrainingGenerationAdmitsNoNewWork() { + GrpcChannelRuntime first = registry.install(generation(1L)); + first.startUnaryCall(); + first.openStream(); + registry.rotate(generation(2L), NOW); + + assertThat(first.startUnaryCall()).isFalse(); + assertThat(first.openStream()).isFalse(); + assertThat(first.quiescent()).isFalse(); + + first.finishUnaryCall(); + first.closeStream(); + assertThat(first.quiescent()).isTrue(); + } + + @Test + @DisplayName("a quiescent draining generation is retired") + void quiescentGenerationsAreRetired() { + GrpcChannelRuntime first = registry.install(generation(1L)); + first.startUnaryCall(); + registry.rotate(generation(2L), NOW); + + assertThat(registry.retireQuiescent(PROFILE)).isZero(); + first.finishUnaryCall(); + assertThat(registry.retireQuiescent(PROFILE)).isEqualTo(1); + assertThat(registry.draining(PROFILE)).isEmpty(); + } + + @Test + @DisplayName("a rotation that goes backwards is refused") + void rotationIsMonotonic() { + registry.install(generation(2L)); + + assertThatThrownBy(() -> registry.rotate(generation(1L), NOW)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not supersede"); + } + + @Test + @DisplayName("unary and stream drain budgets are separate") + void unaryAndStreamDrainBudgetsAreSeparate() { + GrpcChannelDrainPolicy policy = GrpcChannelDrainPolicy.stable(); + + assertThat(policy.unaryDrain()).isLessThan(policy.streamDrain()); + assertThat(policy.forceCancel()).isTrue(); + assertThatThrownBy(() -> new GrpcChannelDrainPolicy(Duration.ZERO, Duration.ZERO, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("alive forever"); + } + + @Test + @DisplayName("the runtime does not hand out a raw channel") + void theRuntimeExposesNoRawChannel() { + assertThat(GrpcChannelRuntime.class.getMethods()) + .extracting(java.lang.reflect.Method::getReturnType) + .extracting(Class::getName) + .noneMatch(name -> name.startsWith("io.grpc.")); + } +} diff --git a/src/grpc/grpc-client/src/test/java/dev/caskeleton/grpc/client/GrpcClientMetadataPolicyTest.java b/src/grpc/grpc-client/src/test/java/dev/caskeleton/grpc/client/GrpcClientMetadataPolicyTest.java new file mode 100644 index 00000000..05bff8b0 --- /dev/null +++ b/src/grpc/grpc-client/src/test/java/dev/caskeleton/grpc/client/GrpcClientMetadataPolicyTest.java @@ -0,0 +1,165 @@ +package dev.caskeleton.grpc.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.context.GrpcMetadataBudget; +import dev.caskeleton.grpc.context.GrpcMetadataKey; +import dev.caskeleton.grpc.core.GrpcChannelProfileName; +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcClientMetadataPolicyTest { + + private static final GrpcMetadataKey CORRELATION = GrpcMetadataKey.ascii("x-correlation-id"); + private static final GrpcMetadataKey TENANT = GrpcMetadataKey.ascii("x-tenant-context"); + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + + private static GrpcClientMetadataPolicy policy(boolean crossesBoundary) { + return new GrpcClientMetadataPolicy( + Set.of(CORRELATION, TENANT), + Set.of(CORRELATION), + GrpcMetadataBudget.standard(), + crossesBoundary); + } + + private static GrpcChannelRuntime runtime() { + return new GrpcChannelRuntime( + new GrpcChannelGeneration( + new GrpcChannelProfileName("documents-read"), 1L, "sha256:config-1", NOW)); + } + + @Test + @DisplayName("authorization is supplied per call, never set as ordinary metadata") + void authorizationIsNotOrdinaryMetadata() { + assertThatThrownBy( + () -> + new GrpcClientMetadataPolicy( + Set.of(GrpcMetadataKey.ascii("authorization")), + Set.of(), + GrpcMetadataBudget.standard(), + false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("survives a rotation"); + } + + @Test + @DisplayName("a channel that crosses a trust boundary sends the smaller allowlist") + void aCrossBoundaryChannelSendsLess() { + Map proposed = Map.of(CORRELATION, "corr-1", TENANT, "tenant-1"); + + assertThat(policy(false).materialize(proposed)).containsOnlyKeys(CORRELATION, TENANT); + assertThat(policy(true).materialize(proposed)).containsOnlyKeys(CORRELATION); + assertThat(policy(true).effectiveAllowlist()).containsExactly(CORRELATION); + } + + @Test + @DisplayName("the cross-boundary allowlist must be a subset of the same-domain one") + void theCrossBoundaryListMustBeASubset() { + assertThatThrownBy( + () -> + new GrpcClientMetadataPolicy( + Set.of(CORRELATION), Set.of(TENANT), GrpcMetadataBudget.standard(), true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("is a subset"); + } + + @Test + @DisplayName("a key outside the allowlist is dropped outbound rather than failing the call") + void unknownKeysAreDroppedOutbound() { + Map proposed = new LinkedHashMap<>(); + proposed.put(CORRELATION, "corr-1"); + proposed.put(GrpcMetadataKey.ascii("x-internal-hop"), "flow-3"); + + assertThat(policy(false).materialize(proposed)).containsOnlyKeys(CORRELATION); + } + + @Test + @DisplayName("the budget is still enforced on what survives the allowlist") + void theBudgetIsEnforcedOnTheSurvivors() { + GrpcClientMetadataPolicy tight = + new GrpcClientMetadataPolicy( + Set.of(CORRELATION), Set.of(CORRELATION), new GrpcMetadataBudget(200, 40, 4), false); + + assertThatThrownBy(() -> tight.materialize(Map.of(CORRELATION, "x".repeat(64)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("user-defined metadata"); + } + + @Test + @DisplayName("the credential is resolved at call time, not captured when the stub was built") + void theCredentialIsResolvedPerCall() { + GrpcCallCredentialProvider provider = + at -> Optional.of("Bearer token-at-" + at.getEpochSecond()); + GrpcDeadlineBudget deadline = + GrpcDeadlineBudget.forEntryPoint( + Duration.ofSeconds(5), GrpcDeadlineProfile.of(Duration.ofSeconds(10))); + + GrpcClientCallContext first = + GrpcClientCallContext.assemble( + GET, runtime(), deadline, policy(false), Map.of(CORRELATION, "corr-1"), provider, NOW); + GrpcClientCallContext later = + GrpcClientCallContext.assemble( + GET, + runtime(), + deadline, + policy(false), + Map.of(CORRELATION, "corr-1"), + provider, + NOW.plusSeconds(60)); + + assertThat(first.credential()).contains("Bearer token-at-" + NOW.getEpochSecond()); + assertThat(later.credential()).isNotEqualTo(first.credential()); + assertThat(GrpcCallCredentialProvider.anonymous().credentialFor(NOW)).isEmpty(); + } + + @Test + @DisplayName("a call whose deadline is already spent is refused before anything is sent") + void aSpentDeadlineIsRefusedBeforeSending() { + assertThatThrownBy( + () -> + GrpcClientCallContext.assemble( + GET, + runtime(), + new GrpcDeadlineBudget( + Duration.ZERO, GrpcDeadlineProfile.of(Duration.ofSeconds(2))), + policy(false), + Map.of(), + GrpcCallCredentialProvider.anonymous(), + NOW)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("deadline is already spent"); + } + + @Test + @DisplayName("a call context copies its metadata, so it cannot change after the call started") + void aCallContextIsImmutable() { + Map proposed = new LinkedHashMap<>(); + proposed.put(CORRELATION, "corr-1"); + GrpcClientCallContext context = + GrpcClientCallContext.assemble( + GET, + runtime(), + GrpcDeadlineBudget.forEntryPoint( + Duration.ofSeconds(5), GrpcDeadlineProfile.of(Duration.ofSeconds(10))), + policy(false), + proposed, + GrpcCallCredentialProvider.anonymous(), + NOW); + + proposed.put(TENANT, "added-after-the-fact"); + + assertThat(context.metadata()).containsOnlyKeys(CORRELATION); + } +} diff --git a/src/grpc/grpc-client/src/test/java/dev/caskeleton/grpc/client/GrpcNamedChannelProfileTest.java b/src/grpc/grpc-client/src/test/java/dev/caskeleton/grpc/client/GrpcNamedChannelProfileTest.java new file mode 100644 index 00000000..aa3355c8 --- /dev/null +++ b/src/grpc/grpc-client/src/test/java/dev/caskeleton/grpc/client/GrpcNamedChannelProfileTest.java @@ -0,0 +1,159 @@ +package dev.caskeleton.grpc.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcChannelProfileName; +import dev.caskeleton.grpc.resilience.GrpcRetryOwner; +import dev.caskeleton.grpc.security.GrpcAuthenticationProfile; +import dev.caskeleton.grpc.security.GrpcTlsProfile; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcNamedChannelProfileTest { + + private static final GrpcTlsProfile TLS = + GrpcTlsProfile.serverAuthenticated(GrpcTlsProfile.Environment.PROD, "trust-bundle"); + + @Test + @DisplayName("a target must carry a Stable resolver scheme") + void aTargetCarriesAStableResolverScheme() { + assertThat( + GrpcNamedChannelProfile.virtualIp( + "documents-read", + URI.create("dns:///documents:9090"), + TLS, + GrpcRetryOwner.GRPC_PLATFORM) + .target() + .getScheme()) + .isEqualTo("dns"); + assertThatThrownBy( + () -> + GrpcNamedChannelProfile.virtualIp( + "documents-read", + URI.create("documents:9090"), + TLS, + GrpcRetryOwner.GRPC_PLATFORM)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("xds and custom schemes are refused as Advanced") + void xdsIsRefusedAsAdvanced() { + assertThatThrownBy( + () -> + GrpcNamedChannelProfile.virtualIp( + "documents-read", + URI.create("xds:///documents"), + TLS, + GrpcRetryOwner.GRPC_PLATFORM)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Advanced"); + } + + @Test + @DisplayName("the Stable load balancing policies are pick_first and round_robin") + void stableLoadBalancingIsTwoPolicies() { + assertThat(GrpcLoadBalancingPolicy.values()) + .containsExactly(GrpcLoadBalancingPolicy.PICK_FIRST, GrpcLoadBalancingPolicy.ROUND_ROBIN); + assertThat(GrpcLoadBalancingPolicy.PICK_FIRST.serviceConfigName()).isEqualTo("pick_first"); + assertThat(GrpcLoadBalancingPolicy.ROUND_ROBIN.requiresMultipleAddresses()).isTrue(); + assertThat(GrpcLoadBalancingPolicy.PICK_FIRST.requiresMultipleAddresses()).isFalse(); + } + + @Test + @DisplayName("round-robin over a single-address target is reported, not silently accepted") + void roundRobinOverOneAddressIsReported() { + GrpcNamedChannelProfile headless = + GrpcNamedChannelProfile.headless( + "documents-stream", + URI.create("dns:///documents-headless:9090"), + TLS, + GrpcRetryOwner.NONE); + + assertThat( + GrpcChannelProfileValidator.violations( + List.of(headless), Map.of("documents-stream", 1))) + .singleElement() + .satisfies(violation -> assertThat(violation).contains("spreading that is not happening")); + assertThat( + GrpcChannelProfileValidator.violations( + List.of(headless), Map.of("documents-stream", 4))) + .isEmpty(); + } + + @Test + @DisplayName("a duplicated profile name is reported") + void aDuplicatedProfileNameIsReported() { + GrpcNamedChannelProfile first = + GrpcNamedChannelProfile.virtualIp( + "documents-read", URI.create("dns:///a:9090"), TLS, GrpcRetryOwner.NONE); + GrpcNamedChannelProfile second = + GrpcNamedChannelProfile.virtualIp( + "documents-read", URI.create("dns:///b:9090"), TLS, GrpcRetryOwner.NONE); + + assertThat(GrpcChannelProfileValidator.violations(List.of(first, second), Map.of())) + .anySatisfy(violation -> assertThat(violation).contains("declared twice")); + } + + @Test + @DisplayName("wait-for-ready with no retry owner is refused") + void waitForReadyWithoutARetryOwnerIsRefused() { + assertThatThrownBy( + () -> + new GrpcNamedChannelProfile( + new GrpcChannelProfileName("documents-read"), + URI.create("dns:///documents:9090"), + GrpcLoadBalancingPolicy.PICK_FIRST, + GrpcRetryOwner.NONE, + TLS, + 1024L, + 1024, + Duration.ofSeconds(30), + true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nobody will re-attempt"); + } + + @Test + @DisplayName("a keep-alive interval is required rather than left at the default") + void aKeepAliveIntervalIsRequired() { + assertThatThrownBy( + () -> + new GrpcNamedChannelProfile( + new GrpcChannelProfileName("documents-read"), + URI.create("dns:///documents:9090"), + GrpcLoadBalancingPolicy.PICK_FIRST, + GrpcRetryOwner.NONE, + TLS, + 1024L, + 1024, + Duration.ZERO, + false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("discovered dead by the next request"); + } + + @Test + @DisplayName("a token profile on a plaintext channel is refused") + void aTokenOnPlaintextIsRefused() { + GrpcNamedChannelProfile plaintext = + GrpcNamedChannelProfile.virtualIp( + "local-dev", + URI.create("dns:///localhost:9090"), + GrpcTlsProfile.plaintextLocal(GrpcTlsProfile.Environment.LOCAL), + GrpcRetryOwner.NONE); + + assertThatThrownBy( + () -> + GrpcChannelProfileValidator.requireCompatibleAuthentication( + plaintext, GrpcAuthenticationProfile.BEARER_TOKEN)) + .isInstanceOf(IllegalStateException.class); + GrpcChannelProfileValidator.requireCompatibleAuthentication( + plaintext, GrpcAuthenticationProfile.NONE); + } +} diff --git a/src/grpc/grpc-client/src/test/java/dev/caskeleton/grpc/client/GrpcTypedStubFactoryTest.java b/src/grpc/grpc-client/src/test/java/dev/caskeleton/grpc/client/GrpcTypedStubFactoryTest.java new file mode 100644 index 00000000..c5eb51a0 --- /dev/null +++ b/src/grpc/grpc-client/src/test/java/dev/caskeleton/grpc/client/GrpcTypedStubFactoryTest.java @@ -0,0 +1,129 @@ +package dev.caskeleton.grpc.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcChannelProfileName; +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import dev.caskeleton.grpc.policy.GrpcMethodPolicyCatalog; +import dev.caskeleton.grpc.resilience.GrpcWaitForReadyProfile; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcTypedStubFactoryTest { + + private static final GrpcChannelProfileName PROFILE = + new GrpcChannelProfileName("documents-read"); + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + + /** Stands in for a generated blocking stub. */ + private record DocumentStub(GrpcChannelGeneration boundTo) {} + + /** A stub type nobody registered. */ + private record UnregisteredStub() {} + + private GrpcChannelRuntimeRegistry registryWithChannel() { + GrpcChannelRuntimeRegistry registry = + new GrpcChannelRuntimeRegistry(GrpcChannelDrainPolicy.stable()); + registry.install(new GrpcChannelGeneration(PROFILE, 1L, "sha256:config-1", NOW)); + return registry; + } + + private static GrpcStubPolicyApplier policyApplier() { + return new GrpcStubPolicyApplier( + GrpcMethodPolicyCatalog.builder() + .register( + GrpcMethodPolicy.readOnlyUnary(GET, GrpcDeadlineProfile.of(Duration.ofSeconds(2)))) + .build(), + GrpcWaitForReadyProfile.disabled()); + } + + private GrpcTypedStubFactory factory(GrpcChannelRuntimeRegistry registry) { + return GrpcTypedStubFactory.builder(registry, policyApplier()) + .register( + new GrpcStubDescriptor<>( + DocumentStub.class, PROFILE, GrpcStubDescriptor.Style.BLOCKING), + runtime -> new DocumentStub(runtime.generation())) + .build(); + } + + @Test + @DisplayName("only registered stub types are built") + void onlyRegisteredStubTypesAreBuilt() { + GrpcTypedStubFactory factory = factory(registryWithChannel()); + + assertThat(factory.stubFor(DocumentStub.class).boundTo().generation()).isEqualTo(1L); + assertThatThrownBy(() -> factory.stubFor(UnregisteredStub.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("no method policy"); + } + + @Test + @DisplayName("a stub is bound to the channel profile its descriptor names") + void aStubIsBoundToItsChannelProfile() { + GrpcTypedStubFactory factory = factory(registryWithChannel()); + + assertThat(factory.channelProfileFor(DocumentStub.class)).isEqualTo(PROFILE); + assertThat(factory.descriptorFor(DocumentStub.class).style()) + .isEqualTo(GrpcStubDescriptor.Style.BLOCKING); + assertThat(factory.registeredStubTypes()).containsExactly(DocumentStub.class); + } + + @Test + @DisplayName("a stub built after a rotation is on the new generation") + void aStubFollowsTheCurrentGeneration() { + GrpcChannelRuntimeRegistry registry = registryWithChannel(); + GrpcTypedStubFactory factory = factory(registry); + registry.rotate(new GrpcChannelGeneration(PROFILE, 2L, "sha256:config-2", NOW), NOW); + + assertThat(factory.stubFor(DocumentStub.class).boundTo().generation()).isEqualTo(2L); + } + + @Test + @DisplayName("a factory with no registrations refuses to build") + void anEmptyFactoryIsRefused() { + assertThatThrownBy( + () -> GrpcTypedStubFactory.builder(registryWithChannel(), policyApplier()).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("refuses everything"); + } + + @Test + @DisplayName("registering one stub type twice is refused") + void duplicateRegistrationIsRefused() { + GrpcTypedStubFactory.Builder builder = + GrpcTypedStubFactory.builder(registryWithChannel(), policyApplier()) + .register( + new GrpcStubDescriptor<>( + DocumentStub.class, PROFILE, GrpcStubDescriptor.Style.BLOCKING), + runtime -> new DocumentStub(runtime.generation())); + + assertThatThrownBy( + () -> + builder.register( + new GrpcStubDescriptor<>( + DocumentStub.class, PROFILE, GrpcStubDescriptor.Style.FUTURE), + runtime -> new DocumentStub(runtime.generation()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("registered twice"); + } + + @Test + @DisplayName("the effective deadline is computed at call time from what the caller has left") + void theDeadlineIsComputedAtCallTime() { + GrpcStubPolicyApplier applier = policyApplier(); + + GrpcStubPolicyApplier.CallPolicy generous = applier.applyTo(GET, Duration.ofSeconds(30)); + GrpcStubPolicyApplier.CallPolicy tight = applier.applyTo(GET, Duration.ofMillis(300)); + + assertThat(generous.deadline().remaining()).isEqualTo(Duration.ofMillis(1800)); + assertThat(tight.deadline().remaining()).isEqualTo(Duration.ofMillis(100)); + assertThat(tight.waitForReady().queue()).isFalse(); + } +} diff --git a/src/grpc/grpc-codegen/build.gradle b/src/grpc/grpc-codegen/build.gradle new file mode 100644 index 00000000..462ce344 --- /dev/null +++ b/src/grpc/grpc-codegen/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'java-library' + +// Contract governance: Buf format/lint/breaking policy, the single codegen owner declaration, and +// the descriptor/schema-hash release artifact with its consumer-compile gate. +// +// The Buf rules are implemented here rather than shelled out to the Buf CLI (adaptation D5): the +// CLI is not present in this toolchain, and a gate that silently no-ops when a binary is missing is +// worse than one that computes the same judgement from the committed schema. +dependencies { + api project(':grpc:grpc-core-api') + api project(':grpc:grpc-proto-contract') +} diff --git a/src/grpc/grpc-codegen/gradle.lockfile b/src/grpc/grpc-codegen/gradle.lockfile new file mode 100644 index 00000000..e2c95854 --- /dev/null +++ b/src/grpc/grpc-codegen/gradle.lockfile @@ -0,0 +1,84 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty=compileClasspath,runtimeClasspath diff --git a/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcBreakingCategory.java b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcBreakingCategory.java new file mode 100644 index 00000000..cdff6618 --- /dev/null +++ b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcBreakingCategory.java @@ -0,0 +1,43 @@ +package dev.caskeleton.grpc.codegen; + +/** + * The Buf breaking-change categories, ordered from strictest to loosest. + * + *

The Stable gate is {@link #FILE}, and the reason is the pair of properties below: only FILE + * and PACKAGE detect a change that keeps the wire format identical while breaking every generated + * consumer's compile. A team that gates on WIRE ships a field rename, watches its own integration + * tests pass, and finds out at the consumer's next build. + */ +public enum GrpcBreakingCategory { + /** Strictest: also catches file moves, package moves and Java option changes. */ + FILE(true, true), + /** Catches type and field source breaks within a package, but not a file move. */ + PACKAGE(true, false), + /** Wire plus JSON name compatibility. Source breaks pass. */ + WIRE_JSON(false, false), + /** Binary wire compatibility only. Source breaks pass. */ + WIRE(false, false); + + private final boolean detectsSourceBreak; + private final boolean detectsFileMove; + + GrpcBreakingCategory(boolean detectsSourceBreak, boolean detectsFileMove) { + this.detectsSourceBreak = detectsSourceBreak; + this.detectsFileMove = detectsFileMove; + } + + /** Whether this category fails a change that stops a generated consumer compiling. */ + public boolean detectsSourceBreak() { + return detectsSourceBreak; + } + + /** Whether this category fails a file or package relocation. */ + public boolean detectsFileMove() { + return detectsFileMove; + } + + /** The category the Stable public API is gated on. */ + public static GrpcBreakingCategory stableGate() { + return FILE; + } +} diff --git a/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcBufPolicy.java b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcBufPolicy.java new file mode 100644 index 00000000..113fd662 --- /dev/null +++ b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcBufPolicy.java @@ -0,0 +1,75 @@ +package dev.caskeleton.grpc.codegen; + +import java.util.List; +import java.util.Set; + +/** + * The schema governance this repository runs, as a value the build and the tests both read. + * + *

Buf's CLI is not part of this toolchain (adaptation D5), so the four lifecycle task names + * below are the contract a CI environment fulfils and {@code GrpcProtoContractValidator} is what + * actually fails a build here. Keeping the task names in the policy rather than only in a workflow + * file means a missing stage is a test failure rather than a stage nobody noticed was gone. + */ +public record GrpcBufPolicy( + GrpcBreakingCategory breakingCategory, + boolean formatEnforced, + boolean lintEnforced, + GrpcSchemaBaseline baseline) { + + /** The lifecycle stages a compliant schema pipeline runs, in order. */ + private static final List REQUIRED_TASKS = + List.of("bufFormatCheck", "bufLint", "bufBuild", "bufBreaking"); + + /** Refuses a policy that would let a source break through. */ + public GrpcBufPolicy { + if (breakingCategory == null) { + throw new IllegalArgumentException("a Buf policy needs a breaking category"); + } + if (baseline == null) { + throw new IllegalArgumentException( + "a Buf policy needs a released baseline to compare against"); + } + if (!breakingCategory.detectsSourceBreak()) { + throw new IllegalArgumentException( + "the Stable public API may not be gated on " + + breakingCategory + + ": wire compatibility alone lets a rename through as compatible while every " + + "generated consumer stops compiling"); + } + if (!formatEnforced || !lintEnforced) { + throw new IllegalArgumentException( + "format and lint are not optional; a schema that is not linted is a schema whose style " + + "rules are whatever the last author preferred"); + } + } + + /** The Stable policy: FILE breaking, format and lint enforced. */ + public static GrpcBufPolicy stable(GrpcSchemaBaseline baseline) { + return new GrpcBufPolicy(GrpcBreakingCategory.stableGate(), true, true, baseline); + } + + /** The lifecycle task names a compliant pipeline registers. */ + public static List requiredTasks() { + return REQUIRED_TASKS; + } + + /** + * The task names missing from {@code registeredTasks}. + * + * @return an empty set when the pipeline is complete + */ + public static Set missingTasks(Set registeredTasks) { + if (registeredTasks == null) { + throw new IllegalArgumentException("the registered task set must not be null"); + } + return REQUIRED_TASKS.stream() + .filter(task -> !registeredTasks.contains(task)) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + } + + /** Whether a candidate schema hash may be released without a breaking review. */ + public boolean unchangedFromBaseline(String candidateSchemaHash) { + return baseline.matches(candidateSchemaHash); + } +} diff --git a/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcCodegenManifest.java b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcCodegenManifest.java new file mode 100644 index 00000000..38fb194b --- /dev/null +++ b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcCodegenManifest.java @@ -0,0 +1,81 @@ +package dev.caskeleton.grpc.codegen; + +/** + * Who generates Java from the schema, with what versions, into where. + * + *

One owner, named. Two generators for one schema is the state in which a type exists twice with + * different options and the classpath decides which one a consumer gets; refusing a second owner is + * cheaper than diagnosing that. + * + *

Both version sources are required to be the managed BOM rather than a literal. A pinned + * protobuf version beside a BOM-managed gRPC version is how the runtime and the generator drift + * into a combination nobody tested, and the symptom is a {@code NoSuchMethodError} in generated + * code. + */ +public record GrpcCodegenManifest( + String codegenOwner, + String protobufVersionSource, + String grpcJavaVersionSource, + GrpcCodegenOutput output, + GrpcGeneratedPackagePolicy packagePolicy) { + + /** The only accepted version source: whatever the build's managed platform resolves. */ + public static final String MANAGED_VERSION_SOURCE = "managed-platform"; + + /** Refuses a second owner, a literal version, or overlapping package namespaces. */ + public GrpcCodegenManifest { + if (codegenOwner == null || codegenOwner.isBlank()) { + throw new IllegalArgumentException("code generation needs exactly one named owner"); + } + if (output == null || packagePolicy == null) { + throw new IllegalArgumentException("a codegen manifest needs an output and a package policy"); + } + requireManagedVersion(protobufVersionSource, "protobuf"); + requireManagedVersion(grpcJavaVersionSource, "grpc-java"); + packagePolicy.requireDisjoint(); + } + + private static void requireManagedVersion(String source, String what) { + if (!MANAGED_VERSION_SOURCE.equals(source)) { + throw new IllegalArgumentException( + what + + " version must come from the managed platform, not from a literal declaration; got '" + + source + + "'. A pinned generator beside a managed runtime is a combination nobody tested."); + } + } + + /** + * This repository's manifest. + * + *

The owner is the Gradle protobuf plugin. It does not run in this build yet — adaptation D6 + * records why, and this manifest is what a future decision to turn it on has to satisfy rather + * than replace. + */ + public static GrpcCodegenManifest caSkeleton() { + return new GrpcCodegenManifest( + "gradle-protobuf-plugin", + MANAGED_VERSION_SOURCE, + MANAGED_VERSION_SOURCE, + GrpcCodegenOutput.standard(), + new GrpcGeneratedPackagePolicy( + java.util.Set.of("dev.caskeleton"), + java.util.Set.of("hyeonworks.grpc.common.v1.generated"))); + } + + /** + * Fails when a second generator claims the same schema. + * + * @throws IllegalStateException when {@code otherOwner} is not this manifest's owner + */ + public void requireSoleOwner(String otherOwner) { + if (!codegenOwner.equals(otherOwner)) { + throw new IllegalStateException( + "Java code generation for this schema is owned by '" + + codegenOwner + + "'; '" + + otherOwner + + "' may not also generate it"); + } + } +} diff --git a/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcCodegenOutput.java b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcCodegenOutput.java new file mode 100644 index 00000000..719efddc --- /dev/null +++ b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcCodegenOutput.java @@ -0,0 +1,58 @@ +package dev.caskeleton.grpc.codegen; + +/** + * Where generated artifacts land. + * + *

Everything here is required to be inside a build directory. A generator that writes into a + * source tree produces files that get committed, then edited, then silently reverted by the next + * regeneration — and the diff that reverts them looks like the generator working correctly. + */ +public record GrpcCodegenOutput( + String javaOutputDirectory, + String grpcJavaOutputDirectory, + String descriptorSetPath, + boolean includeSourceInfo) { + + private static final String BUILD_ROOT = "build/"; + + /** Requires every path to be build-directory relative. */ + public GrpcCodegenOutput { + requireBuildRelative(javaOutputDirectory, "java output directory"); + requireBuildRelative(grpcJavaOutputDirectory, "grpc-java output directory"); + requireBuildRelative(descriptorSetPath, "descriptor set path"); + if (!descriptorSetPath.endsWith(".desc") && !descriptorSetPath.endsWith(".binpb")) { + throw new IllegalArgumentException( + "a descriptor set is a serialized FileDescriptorSet; expected a .desc or .binpb path, got '" + + descriptorSetPath + + "'"); + } + } + + private static void requireBuildRelative(String path, String what) { + if (path == null || path.isBlank()) { + throw new IllegalArgumentException(what + " must not be blank"); + } + if (path.startsWith("/") || path.contains("..")) { + throw new IllegalArgumentException( + what + " must be a project-relative path without '..'; got '" + path + "'"); + } + if (!path.startsWith(BUILD_ROOT)) { + throw new IllegalArgumentException( + what + + " must be under '" + + BUILD_ROOT + + "'; generated sources in a source tree get committed and then edited. Got '" + + path + + "'"); + } + } + + /** This repository's layout. */ + public static GrpcCodegenOutput standard() { + return new GrpcCodegenOutput( + "build/generated/source/proto/main/java", + "build/generated/source/proto/main/grpc", + "build/generated/descriptor/stable-schema.desc", + true); + } +} diff --git a/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcConsumerFixture.java b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcConsumerFixture.java new file mode 100644 index 00000000..189cbfef --- /dev/null +++ b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcConsumerFixture.java @@ -0,0 +1,158 @@ +package dev.caskeleton.grpc.codegen; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * A previously released consumer, described by exactly what it compiles against. + * + *

This is the check a wire-compatibility gate cannot make. A field rename, a service move to a + * new package, or a change to {@code java_package} keeps every serialized message valid and stops + * every generated client compiling — and the only way to notice before the consumer does is to keep + * a record of what the consumer names and compare a candidate schema against it. + */ +public record GrpcConsumerFixture( + String name, + String schemaVersion, + Set requiredServicePaths, + Set requiredMethodPaths, + Set requiredJavaPackages) { + + /** What kind of source break a consumer hit. Reported separately, never merged into one count. */ + public enum BreakKind { + /** A service the consumer names is gone or moved. */ + SERVICE_PATH, + /** A method the consumer calls is gone or renamed. */ + METHOD_PATH, + /** The Java package the consumer imports from moved. */ + JAVA_PACKAGE + } + + /** One thing a consumer needs that a candidate schema no longer provides. */ + public record SourceBreak(BreakKind kind, String missing) { + /** Requires both halves. */ + public SourceBreak { + if (kind == null || missing == null || missing.isBlank()) { + throw new IllegalArgumentException("a source break names its kind and what went missing"); + } + } + } + + /** Copies every requirement set. */ + public GrpcConsumerFixture { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("a consumer fixture needs a name"); + } + if (schemaVersion == null || schemaVersion.isBlank()) { + throw new IllegalArgumentException( + "a consumer fixture names the schema version it was built against"); + } + if (requiredServicePaths == null + || requiredMethodPaths == null + || requiredJavaPackages == null) { + throw new IllegalArgumentException("a consumer fixture states all three requirement sets"); + } + requiredServicePaths = Set.copyOf(requiredServicePaths); + requiredMethodPaths = Set.copyOf(requiredMethodPaths); + requiredJavaPackages = Set.copyOf(requiredJavaPackages); + } + + /** + * Derives a fixture from a released consumer's own source. + * + *

Read from the consumer rather than declared beside it, because a hand-written requirement + * list is a second copy of what the client already says and the copy is the one that stops being + * updated. Three rules, all mechanical: + * + *

    + *
  • a generated Java package is any package a {@code fixture} class imports from; + *
  • a service is a {@code Grpc} import, mapped back to {@code .} with + * the generated-package suffix removed; + *
  • a method is a {@code stub.(} call, mapped to {@code /}. + *
+ * + *

It is an approximation of compiling the fixture, which is what the plan asks for and what + * ADR-GRPC-002 records this repository cannot do yet. It is a useful approximation because it + * fails on exactly the changes a compile would fail on: a renamed method, a moved service, a + * relocated Java package. + * + * @param generatedPackageSuffix the segment appended to a proto package for generated code, e.g. + * {@code generated}; removed to recover the schema's own service path + */ + public static GrpcConsumerFixture fromJavaSource( + String name, String schemaVersion, String javaSource, String generatedPackageSuffix) { + if (javaSource == null || javaSource.isBlank()) { + throw new IllegalArgumentException("a consumer fixture needs its source text"); + } + if (generatedPackageSuffix == null || generatedPackageSuffix.isBlank()) { + throw new IllegalArgumentException("the generated package suffix is required"); + } + + java.util.Set javaPackages = new LinkedHashSet<>(); + java.util.Set servicePaths = new LinkedHashSet<>(); + java.util.regex.Matcher imports = + java.util.regex.Pattern.compile( + "^\\s*import\\s+([\\w.]+)\\.(\\w+);", java.util.regex.Pattern.MULTILINE) + .matcher(javaSource); + while (imports.find()) { + String importedPackage = imports.group(1); + String importedType = imports.group(2); + if (!importedPackage.endsWith("." + generatedPackageSuffix)) { + continue; + } + javaPackages.add(importedPackage); + if (importedType.endsWith("Grpc")) { + String protoPackage = + importedPackage.substring( + 0, importedPackage.length() - generatedPackageSuffix.length() - 1); + servicePaths.add( + protoPackage + + "." + + importedType.substring(0, importedType.length() - "Grpc".length())); + } + } + + java.util.Set methodPaths = new LinkedHashSet<>(); + java.util.regex.Matcher calls = + java.util.regex.Pattern.compile("\\bstub\\.(\\w+)\\s*\\(").matcher(javaSource); + while (calls.find()) { + String method = calls.group(1); + String upperCamel = Character.toUpperCase(method.charAt(0)) + method.substring(1); + servicePaths.forEach(service -> methodPaths.add(service + "/" + upperCamel)); + } + + if (servicePaths.isEmpty() || methodPaths.isEmpty()) { + throw new IllegalArgumentException( + "no service or method could be derived from '" + + name + + "'; a fixture that requires nothing cannot detect a break"); + } + return new GrpcConsumerFixture(name, schemaVersion, servicePaths, methodPaths, javaPackages); + } + + /** + * What this consumer would fail to compile against {@code candidate}. + * + * @return an empty list when the consumer still compiles + */ + public List breaksAgainst(GrpcDescriptorArtifact candidate) { + if (candidate == null) { + throw new IllegalArgumentException("a compatibility check needs a candidate artifact"); + } + List breaks = new ArrayList<>(); + collect(breaks, BreakKind.SERVICE_PATH, requiredServicePaths, candidate.servicePaths()); + collect(breaks, BreakKind.METHOD_PATH, requiredMethodPaths, candidate.methodPaths()); + collect( + breaks, BreakKind.JAVA_PACKAGE, requiredJavaPackages, candidate.generatedJavaPackages()); + return List.copyOf(breaks); + } + + private static void collect( + List breaks, BreakKind kind, Set required, Set available) { + Set missing = new LinkedHashSet<>(required); + missing.removeAll(available); + missing.stream().sorted().forEach(entry -> breaks.add(new SourceBreak(kind, entry))); + } +} diff --git a/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcDescriptorArtifact.java b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcDescriptorArtifact.java new file mode 100644 index 00000000..c6a1c39f --- /dev/null +++ b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcDescriptorArtifact.java @@ -0,0 +1,59 @@ +package dev.caskeleton.grpc.codegen; + +import java.util.Set; + +/** + * One immutable release of the schema: what it contains, and the hashes that prove which bytes it + * was built from. + * + *

Service and method paths are carried explicitly, not derived on demand, because they are what + * a consumer compatibility check compares. A descriptor that only carries a digest can say + * "something changed" and nothing else, which is the answer least useful to the person who has to + * decide whether to release. + */ +public record GrpcDescriptorArtifact( + String schemaVersion, + String schemaHash, + String descriptorSetDigest, + String bufImageDigest, + String policyVersion, + Set servicePaths, + Set methodPaths, + Set generatedJavaPackages) { + + /** Requires an immutable version, three digests and a non-empty surface. */ + public GrpcDescriptorArtifact { + if (schemaVersion == null || schemaVersion.isBlank() || schemaVersion.endsWith("-SNAPSHOT")) { + throw new IllegalArgumentException( + "a schema artifact publishes an immutable version; got '" + schemaVersion + "'"); + } + requireDigest(schemaHash, "schema hash"); + requireDigest(descriptorSetDigest, "descriptor set digest"); + requireDigest(bufImageDigest, "buf image digest"); + if (policyVersion == null || policyVersion.isBlank()) { + throw new IllegalArgumentException( + "a schema artifact records the policy version it was judged under"); + } + if (servicePaths == null || methodPaths == null || generatedJavaPackages == null) { + throw new IllegalArgumentException( + "a schema artifact carries its service, method and package surface"); + } + if (methodPaths.isEmpty()) { + throw new IllegalArgumentException("a schema artifact with no methods describes nothing"); + } + servicePaths = Set.copyOf(servicePaths); + methodPaths = Set.copyOf(methodPaths); + generatedJavaPackages = Set.copyOf(generatedJavaPackages); + } + + private static void requireDigest(String digest, String what) { + if (digest == null || !digest.startsWith("sha256:") || digest.length() < 15) { + throw new IllegalArgumentException(what + " must be 'sha256:'; got '" + digest + "'"); + } + } + + /** Whether this artifact is byte-identical to {@code baseline}. */ + public boolean unchangedFrom(GrpcSchemaBaseline baseline) { + return baseline.matches(schemaHash); + } +} diff --git a/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcGeneratedPackagePolicy.java b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcGeneratedPackagePolicy.java new file mode 100644 index 00000000..fd74568d --- /dev/null +++ b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcGeneratedPackagePolicy.java @@ -0,0 +1,68 @@ +package dev.caskeleton.grpc.codegen; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * Keeps generated Java packages and hand-written ones apart. + * + *

An overlap is not a style problem. Generated code is deleted and rewritten on every build, so + * a hand-written class that shares its package is one clean build away from being either + * overwritten or, more often, from making the generator's output unbuildable in a way that reads as + * a compiler bug. Splitting the namespaces makes the ownership of every file legible from its + * package alone. + */ +public record GrpcGeneratedPackagePolicy( + Set handWrittenPackages, Set generatedPackages) { + + /** Copies both sets. */ + public GrpcGeneratedPackagePolicy { + if (handWrittenPackages == null || generatedPackages == null) { + throw new IllegalArgumentException("both package sets must be present"); + } + if (generatedPackages.isEmpty()) { + throw new IllegalArgumentException("a codegen policy names at least one generated package"); + } + handWrittenPackages = Set.copyOf(handWrittenPackages); + generatedPackages = Set.copyOf(generatedPackages); + } + + /** + * Every overlap between the two namespaces, described. + * + * @return an empty list when the namespaces are disjoint + */ + public List overlaps() { + List overlaps = new ArrayList<>(); + for (String generated : generatedPackages) { + for (String handWritten : handWrittenPackages) { + if (generated.equals(handWritten) + || generated.startsWith(handWritten + ".") + || handWritten.startsWith(generated + ".")) { + overlaps.add( + "generated package '" + + generated + + "' overlaps hand-written package '" + + handWritten + + "'"); + } + } + } + overlaps.sort(java.util.Comparator.naturalOrder()); + return List.copyOf(overlaps); + } + + /** + * Fails when the namespaces overlap. + * + * @throws IllegalStateException naming every overlap + */ + public void requireDisjoint() { + List overlaps = overlaps(); + if (!overlaps.isEmpty()) { + throw new IllegalStateException( + "generated and hand-written Java packages must be disjoint: " + overlaps); + } + } +} diff --git a/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcSchemaArtifactPublisher.java b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcSchemaArtifactPublisher.java new file mode 100644 index 00000000..225894c3 --- /dev/null +++ b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcSchemaArtifactPublisher.java @@ -0,0 +1,113 @@ +package dev.caskeleton.grpc.codegen; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Decides whether a candidate schema may be published, and refuses to let a released version change + * underneath its consumers. + * + *

Two rules, and the second is the one that gets skipped in a hurry: a consumer fixture that no + * longer compiles blocks the release. Publishing anyway and telling the consumers is not the same + * thing — by then the artifact exists, somebody has resolved it, and the fix is a new version + * rather than a decision not to make the change. + */ +public final class GrpcSchemaArtifactPublisher { + + private final GrpcBufPolicy policy; + private final Map publishedHashesByVersion = new LinkedHashMap<>(); + + /** Binds a publisher to the governance policy it enforces. */ + public GrpcSchemaArtifactPublisher(GrpcBufPolicy policy) { + if (policy == null) { + throw new IllegalArgumentException("a publisher needs a Buf policy"); + } + this.policy = policy; + } + + /** Whether a candidate may be published, and if not, why not. */ + public record PublishDecision(boolean allowed, List blockers) { + /** Copies the blocker list. */ + public PublishDecision { + if (blockers == null) { + throw new IllegalArgumentException( + "a publish decision lists its blockers, even when empty"); + } + blockers = List.copyOf(blockers); + if (allowed && !blockers.isEmpty()) { + throw new IllegalArgumentException("an allowed publish has no blockers"); + } + if (!allowed && blockers.isEmpty()) { + throw new IllegalArgumentException("a refused publish says why"); + } + } + } + + /** + * Evaluates a candidate against the policy and every consumer fixture. + * + * @param fixtures the previously released consumers this schema must keep compiling + */ + public PublishDecision evaluate( + GrpcDescriptorArtifact candidate, List fixtures) { + if (candidate == null || fixtures == null) { + throw new IllegalArgumentException("evaluation needs a candidate and a fixture list"); + } + List blockers = new ArrayList<>(); + + String alreadyPublished = publishedHashesByVersion.get(candidate.schemaVersion()); + if (alreadyPublished != null && !alreadyPublished.equals(candidate.schemaHash())) { + blockers.add( + "version '" + + candidate.schemaVersion() + + "' is already published with a different schema hash; a released schema version is " + + "immutable"); + } + if (!policy.breakingCategory().detectsSourceBreak()) { + blockers.add("the active breaking category does not detect source breaks"); + } + for (GrpcConsumerFixture fixture : fixtures) { + for (GrpcConsumerFixture.SourceBreak sourceBreak : fixture.breaksAgainst(candidate)) { + blockers.add( + "consumer '" + + fixture.name() + + "' (" + + fixture.schemaVersion() + + ") loses " + + sourceBreak.kind() + + " '" + + sourceBreak.missing() + + "'"); + } + } + return new PublishDecision(blockers.isEmpty(), blockers); + } + + /** + * Records a successful publish. + * + * @throws IllegalStateException when the decision refused it + */ + public void publish(GrpcDescriptorArtifact candidate, PublishDecision decision) { + if (decision == null || !decision.allowed()) { + throw new IllegalStateException( + "refusing to publish '" + + (candidate == null ? "null" : candidate.schemaVersion()) + + "': " + + (decision == null ? "no decision" : decision.blockers())); + } + publishedHashesByVersion.put(candidate.schemaVersion(), candidate.schemaHash()); + } + + /** The hash published for {@code version}, or null when nothing was. */ + public String publishedHash(String version) { + return publishedHashesByVersion.get(version); + } + + /** The governance policy in force. */ + public GrpcBufPolicy policy() { + return policy; + } +} diff --git a/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcSchemaBaseline.java b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcSchemaBaseline.java new file mode 100644 index 00000000..c0468ba0 --- /dev/null +++ b/src/grpc/grpc-codegen/src/main/java/dev/caskeleton/grpc/codegen/GrpcSchemaBaseline.java @@ -0,0 +1,37 @@ +package dev.caskeleton.grpc.codegen; + +import java.time.Instant; + +/** + * The released schema a breaking check compares against. + * + *

Pinned to a released artifact rather than to a branch, and that is the whole point. Comparing + * against the previous commit answers "did this commit break anything", which is not the question: + * a breaking change introduced two commits ago and refined since then passes every commit-to-commit + * check while being broken against everything that has actually been deployed. + */ +public record GrpcSchemaBaseline(String version, String schemaHash, Instant releasedAt) { + + /** Requires an immutable version, a hash and a release moment. */ + public GrpcSchemaBaseline { + if (version == null || version.isBlank()) { + throw new IllegalArgumentException("a schema baseline needs a released version"); + } + if (version.endsWith("-SNAPSHOT")) { + throw new IllegalArgumentException( + "a breaking baseline must be an immutable release, not '" + version + "'"); + } + if (schemaHash == null || !schemaHash.startsWith("sha256:") || schemaHash.length() < 15) { + throw new IllegalArgumentException( + "a schema baseline needs a 'sha256:' hash; got '" + schemaHash + "'"); + } + if (releasedAt == null) { + throw new IllegalArgumentException("a schema baseline needs a release timestamp"); + } + } + + /** Whether {@code candidateHash} is byte-identical to this baseline. */ + public boolean matches(String candidateHash) { + return schemaHash.equals(candidateHash); + } +} diff --git a/src/grpc/grpc-codegen/src/test/java/dev/caskeleton/grpc/codegen/GrpcBufPolicyTest.java b/src/grpc/grpc-codegen/src/test/java/dev/caskeleton/grpc/codegen/GrpcBufPolicyTest.java new file mode 100644 index 00000000..efb27560 --- /dev/null +++ b/src/grpc/grpc-codegen/src/test/java/dev/caskeleton/grpc/codegen/GrpcBufPolicyTest.java @@ -0,0 +1,88 @@ +package dev.caskeleton.grpc.codegen; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcBufPolicyTest { + + private static final GrpcSchemaBaseline BASELINE = + new GrpcSchemaBaseline( + "1.4.0", + "sha256:0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0", + Instant.parse("2026-08-01T00:00:00Z")); + + @Test + @DisplayName("the Stable public API is gated on the FILE breaking category") + void stablePublicApiUsesTheFileCategory() { + GrpcBufPolicy policy = GrpcBufPolicy.stable(BASELINE); + + assertThat(policy.breakingCategory()).isEqualTo(GrpcBreakingCategory.FILE); + assertThat(GrpcBreakingCategory.stableGate()).isEqualTo(GrpcBreakingCategory.FILE); + assertThat(policy.breakingCategory().detectsSourceBreak()).isTrue(); + assertThat(policy.breakingCategory().detectsFileMove()).isTrue(); + } + + @Test + @DisplayName("wire-only compatibility may not gate the Stable public API") + void wireOnlyCompatibilityIsRefused() { + assertThatThrownBy(() -> new GrpcBufPolicy(GrpcBreakingCategory.WIRE, true, true, BASELINE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("stops compiling"); + assertThatThrownBy( + () -> new GrpcBufPolicy(GrpcBreakingCategory.WIRE_JSON, true, true, BASELINE)) + .isInstanceOf(IllegalArgumentException.class); + assertThat(GrpcBreakingCategory.WIRE.detectsSourceBreak()).isFalse(); + assertThat(GrpcBreakingCategory.PACKAGE.detectsFileMove()).isFalse(); + } + + @Test + @DisplayName("format and lint are not optional") + void formatAndLintAreNotOptional() { + assertThatThrownBy(() -> new GrpcBufPolicy(GrpcBreakingCategory.FILE, false, true, BASELINE)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new GrpcBufPolicy(GrpcBreakingCategory.FILE, true, false, BASELINE)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the four lifecycle stages are named, and a missing one is reported") + void lifecycleStagesAreNamedAndChecked() { + assertThat(GrpcBufPolicy.requiredTasks()) + .containsExactly("bufFormatCheck", "bufLint", "bufBuild", "bufBreaking"); + assertThat(GrpcBufPolicy.missingTasks(Set.of("bufFormatCheck", "bufLint", "bufBuild"))) + .containsExactly("bufBreaking"); + assertThat(GrpcBufPolicy.missingTasks(Set.copyOf(GrpcBufPolicy.requiredTasks()))).isEmpty(); + } + + @Test + @DisplayName("the breaking baseline is a released version, never a snapshot") + void baselineIsAnImmutableRelease() { + assertThatThrownBy( + () -> + new GrpcSchemaBaseline( + "1.5.0-SNAPSHOT", + "sha256:0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0", + Instant.parse("2026-08-01T00:00:00Z"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("immutable release"); + assertThatThrownBy( + () -> + new GrpcSchemaBaseline( + "1.5.0", "not-a-digest", Instant.parse("2026-08-01T00:00:00Z"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("an unchanged schema hash needs no breaking review") + void unchangedSchemaNeedsNoBreakingReview() { + GrpcBufPolicy policy = GrpcBufPolicy.stable(BASELINE); + + assertThat(policy.unchangedFromBaseline(BASELINE.schemaHash())).isTrue(); + assertThat(policy.unchangedFromBaseline("sha256:ffffffffffffffffffffffffffffffff")).isFalse(); + } +} diff --git a/src/grpc/grpc-codegen/src/test/java/dev/caskeleton/grpc/codegen/GrpcCodegenManifestTest.java b/src/grpc/grpc-codegen/src/test/java/dev/caskeleton/grpc/codegen/GrpcCodegenManifestTest.java new file mode 100644 index 00000000..c579df37 --- /dev/null +++ b/src/grpc/grpc-codegen/src/test/java/dev/caskeleton/grpc/codegen/GrpcCodegenManifestTest.java @@ -0,0 +1,78 @@ +package dev.caskeleton.grpc.codegen; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcCodegenManifestTest { + + @Test + @DisplayName("Java code generation has exactly one owner, and a second is refused") + void codeGenerationHasOneOwner() { + GrpcCodegenManifest manifest = GrpcCodegenManifest.caSkeleton(); + + manifest.requireSoleOwner("gradle-protobuf-plugin"); + assertThatThrownBy(() -> manifest.requireSoleOwner("hand-rolled-protoc-exec")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("hand-rolled-protoc-exec"); + } + + @Test + @DisplayName("generator versions come from the managed platform, never from a literal") + void generatorVersionsComeFromTheManagedPlatform() { + assertThatThrownBy( + () -> + new GrpcCodegenManifest( + "gradle-protobuf-plugin", + "3.25.5", + GrpcCodegenManifest.MANAGED_VERSION_SOURCE, + GrpcCodegenOutput.standard(), + new GrpcGeneratedPackagePolicy( + Set.of("dev.caskeleton"), Set.of("hyeonworks.grpc.common.v1.generated")))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("protobuf version must come from the managed platform"); + } + + @Test + @DisplayName("generated sources land under build/, never in a source tree") + void generatedSourcesLandUnderBuild() { + assertThatThrownBy( + () -> new GrpcCodegenOutput("src/main/java", "build/g", "build/x.desc", true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be under 'build/'"); + assertThatThrownBy(() -> new GrpcCodegenOutput("build/a", "build/b", "build/schema.txt", true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("FileDescriptorSet"); + assertThat(GrpcCodegenOutput.standard().includeSourceInfo()).isTrue(); + } + + @Test + @DisplayName("a generated package inside a hand-written one fails the manifest") + void generatedAndHandWrittenPackagesMustBeDisjoint() { + GrpcGeneratedPackagePolicy overlapping = + new GrpcGeneratedPackagePolicy( + Set.of("dev.caskeleton"), Set.of("dev.caskeleton.grpc.generated")); + + assertThat(overlapping.overlaps()).hasSize(1); + assertThatThrownBy(overlapping::requireDisjoint) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("dev.caskeleton.grpc.generated"); + assertThat( + new GrpcGeneratedPackagePolicy( + Set.of("dev.caskeleton"), Set.of("hyeonworks.grpc.common.v1.generated")) + .overlaps()) + .isEmpty(); + } + + @Test + @DisplayName("an overlap is caught in both directions") + void overlapIsSymmetric() { + GrpcGeneratedPackagePolicy generatedIsAnAncestor = + new GrpcGeneratedPackagePolicy(Set.of("hyeonworks.grpc.hand"), Set.of("hyeonworks.grpc")); + + assertThat(generatedIsAnAncestor.overlaps()).hasSize(1); + } +} diff --git a/src/grpc/grpc-codegen/src/test/java/dev/caskeleton/grpc/codegen/GrpcDescriptorArtifactTest.java b/src/grpc/grpc-codegen/src/test/java/dev/caskeleton/grpc/codegen/GrpcDescriptorArtifactTest.java new file mode 100644 index 00000000..fb25a9a7 --- /dev/null +++ b/src/grpc/grpc-codegen/src/test/java/dev/caskeleton/grpc/codegen/GrpcDescriptorArtifactTest.java @@ -0,0 +1,248 @@ +package dev.caskeleton.grpc.codegen; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcDescriptorArtifactTest { + + private static final String DIGEST_A = + "sha256:0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0"; + private static final String DIGEST_B = + "sha256:1122334455667788990011223344556677889900112233445566778899001122"; + + private static final GrpcSchemaBaseline BASELINE = + new GrpcSchemaBaseline("1.4.0", DIGEST_A, Instant.parse("2026-08-01T00:00:00Z")); + + private static GrpcDescriptorArtifact artifact( + String version, String hash, Set methods, Set javaPackages) { + return new GrpcDescriptorArtifact( + version, + hash, + DIGEST_A, + DIGEST_B, + "grpc-stable-policy-1", + Set.of("hyeonworks.document.v1.DocumentService"), + methods, + javaPackages); + } + + private static final Set METHODS = + Set.of( + "hyeonworks.document.v1.DocumentService/GetDocument", + "hyeonworks.document.v1.DocumentService/CreateDocument"); + private static final Set PACKAGES = Set.of("hyeonworks.document.v1.generated"); + + private static String resource(String path) { + try (java.io.InputStream stream = + GrpcDescriptorArtifactTest.class.getClassLoader().getResourceAsStream(path)) { + if (stream == null) { + throw new IllegalStateException("missing test resource " + path); + } + return new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } catch (java.io.IOException e) { + throw new java.io.UncheckedIOException(e); + } + } + + @Test + @DisplayName("a schema artifact publishes an immutable version and three digests") + void artifactCarriesAnImmutableVersionAndDigests() { + GrpcDescriptorArtifact candidate = artifact("1.5.0", DIGEST_B, METHODS, PACKAGES); + + assertThat(candidate.schemaVersion()).isEqualTo("1.5.0"); + assertThat(candidate.unchangedFrom(BASELINE)).isFalse(); + assertThat(artifact("1.4.0", DIGEST_A, METHODS, PACKAGES).unchangedFrom(BASELINE)).isTrue(); + assertThatThrownBy(() -> artifact("1.5.0-SNAPSHOT", DIGEST_B, METHODS, PACKAGES)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("immutable version"); + assertThatThrownBy(() -> artifact("1.5.0", "nope", METHODS, PACKAGES)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("service, method and Java package breaks are reported separately") + void sourceBreaksAreReportedByKind() { + GrpcConsumerFixture consumer = + new GrpcConsumerFixture( + "document-client-v1", + "1.4.0", + Set.of("hyeonworks.document.v1.DocumentService"), + METHODS, + PACKAGES); + GrpcDescriptorArtifact renamedEverything = + new GrpcDescriptorArtifact( + "1.5.0", + DIGEST_B, + DIGEST_A, + DIGEST_B, + "grpc-stable-policy-1", + Set.of("hyeonworks.document.v2.DocumentService"), + Set.of("hyeonworks.document.v2.DocumentService/GetDocument"), + Set.of("hyeonworks.document.v2.generated")); + + List breaks = consumer.breaksAgainst(renamedEverything); + + assertThat(breaks) + .extracting(GrpcConsumerFixture.SourceBreak::kind) + .containsExactlyInAnyOrder( + GrpcConsumerFixture.BreakKind.SERVICE_PATH, + GrpcConsumerFixture.BreakKind.METHOD_PATH, + GrpcConsumerFixture.BreakKind.METHOD_PATH, + GrpcConsumerFixture.BreakKind.JAVA_PACKAGE); + } + + @Test + @DisplayName("a consumer that still compiles reports no break") + void compatibleSchemaBreaksNothing() { + GrpcConsumerFixture consumer = + new GrpcConsumerFixture( + "document-client-v1", + "1.4.0", + Set.of("hyeonworks.document.v1.DocumentService"), + METHODS, + PACKAGES); + Set widened = + Set.of( + "hyeonworks.document.v1.DocumentService/GetDocument", + "hyeonworks.document.v1.DocumentService/CreateDocument", + "hyeonworks.document.v1.DocumentService/ArchiveDocument"); + + assertThat(consumer.breaksAgainst(artifact("1.5.0", DIGEST_B, widened, PACKAGES))).isEmpty(); + } + + @Test + @DisplayName("the committed v1 consumer fixture states its own requirements") + void theCommittedFixtureStatesItsOwnRequirements() { + GrpcConsumerFixture derived = + GrpcConsumerFixture.fromJavaSource( + "document-client-v1", + "1.4.0", + resource("consumer-fixtures/v1/src/main/java/fixture/DocumentClientFixture.java"), + "generated"); + + assertThat(derived.requiredServicePaths()) + .containsExactly("hyeonworks.document.v1.DocumentService"); + assertThat(derived.requiredMethodPaths()) + .containsExactlyInAnyOrder( + "hyeonworks.document.v1.DocumentService/GetDocument", + "hyeonworks.document.v1.DocumentService/CreateDocument"); + assertThat(derived.requiredJavaPackages()).containsExactly("hyeonworks.document.v1.generated"); + } + + @Test + @DisplayName("a schema that renames a method the committed fixture calls blocks the release") + void renamingAMethodTheFixtureCallsBlocksTheRelease() { + GrpcConsumerFixture derived = + GrpcConsumerFixture.fromJavaSource( + "document-client-v1", + "1.4.0", + resource("consumer-fixtures/v1/src/main/java/fixture/DocumentClientFixture.java"), + "generated"); + GrpcDescriptorArtifact renamed = + new GrpcDescriptorArtifact( + "1.5.0", + DIGEST_B, + DIGEST_A, + DIGEST_B, + "grpc-stable-policy-1", + Set.of("hyeonworks.document.v1.DocumentService"), + Set.of( + "hyeonworks.document.v1.DocumentService/GetDocument", + "hyeonworks.document.v1.DocumentService/CreateDocumentV2"), + Set.of("hyeonworks.document.v1.generated")); + + GrpcSchemaArtifactPublisher publisher = + new GrpcSchemaArtifactPublisher(GrpcBufPolicy.stable(BASELINE)); + + assertThat(publisher.evaluate(renamed, List.of(derived)).blockers()) + .anySatisfy(blocker -> assertThat(blocker).contains("CreateDocument")); + } + + @Test + @DisplayName("a fixture that requires nothing is refused rather than passing every schema") + void aFixtureThatRequiresNothingIsRefused() { + assertThatThrownBy( + () -> + GrpcConsumerFixture.fromJavaSource( + "empty-client", "1.4.0", "package fixture;\n\nclass Empty {}\n", "generated")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot detect a break"); + } + + @Test + @DisplayName("the fixture's own build file pins the schema version it was built against") + void theFixturePinsItsSchemaVersion() { + assertThat(resource("consumer-fixtures/v1/build.gradle.kts")) + .contains("grpc-schema-artifact:1.4.0") + .contains("floats to the latest version"); + } + + @Test + @DisplayName("a consumer fixture failure blocks the release") + void consumerFixtureFailureBlocksTheRelease() { + GrpcSchemaArtifactPublisher publisher = + new GrpcSchemaArtifactPublisher(GrpcBufPolicy.stable(BASELINE)); + GrpcConsumerFixture consumer = + new GrpcConsumerFixture( + "document-client-v1", + "1.4.0", + Set.of("hyeonworks.document.v1.DocumentService"), + METHODS, + PACKAGES); + GrpcDescriptorArtifact dropsAMethod = + artifact( + "1.5.0", + DIGEST_B, + Set.of("hyeonworks.document.v1.DocumentService/GetDocument"), + PACKAGES); + + GrpcSchemaArtifactPublisher.PublishDecision decision = + publisher.evaluate(dropsAMethod, List.of(consumer)); + + assertThat(decision.allowed()).isFalse(); + assertThat(decision.blockers()) + .anySatisfy( + blocker -> + assertThat(blocker).contains("document-client-v1").contains("CreateDocument")); + assertThatThrownBy(() -> publisher.publish(dropsAMethod, decision)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("a released version may not be republished with different bytes") + void releasedVersionsAreImmutable() { + GrpcSchemaArtifactPublisher publisher = + new GrpcSchemaArtifactPublisher(GrpcBufPolicy.stable(BASELINE)); + GrpcDescriptorArtifact first = artifact("1.5.0", DIGEST_B, METHODS, PACKAGES); + + GrpcSchemaArtifactPublisher.PublishDecision allowed = publisher.evaluate(first, List.of()); + assertThat(allowed.allowed()).isTrue(); + publisher.publish(first, allowed); + assertThat(publisher.publishedHash("1.5.0")).isEqualTo(DIGEST_B); + + GrpcDescriptorArtifact sameVersionDifferentBytes = + artifact("1.5.0", DIGEST_A, METHODS, PACKAGES); + + assertThat(publisher.evaluate(sameVersionDifferentBytes, List.of()).blockers()) + .anySatisfy(blocker -> assertThat(blocker).contains("immutable")); + } + + @Test + @DisplayName("republishing identical bytes under the same version is allowed") + void identicalRepublishIsAllowed() { + GrpcSchemaArtifactPublisher publisher = + new GrpcSchemaArtifactPublisher(GrpcBufPolicy.stable(BASELINE)); + GrpcDescriptorArtifact artifact = artifact("1.5.0", DIGEST_B, METHODS, PACKAGES); + + publisher.publish(artifact, publisher.evaluate(artifact, List.of())); + + assertThat(publisher.evaluate(artifact, List.of()).allowed()).isTrue(); + assertThat(publisher.policy().breakingCategory()).isEqualTo(GrpcBreakingCategory.FILE); + } +} diff --git a/src/grpc/grpc-codegen/src/test/resources/consumer-fixtures/v1/build.gradle.kts b/src/grpc/grpc-codegen/src/test/resources/consumer-fixtures/v1/build.gradle.kts new file mode 100644 index 00000000..3351027c --- /dev/null +++ b/src/grpc/grpc-codegen/src/test/resources/consumer-fixtures/v1/build.gradle.kts @@ -0,0 +1,17 @@ +// The v1 consumer fixture's own build file, kept as a test resource. +// +// It is not part of this repository's Gradle build. Its purpose is to record what a released +// consumer actually depended on — the schema artifact version and nothing else — so that when +// protoc generation is switched on (ADR-GRPC-002) this directory can be compiled as-is against a +// candidate schema, which is the check GrpcConsumerFixture currently approximates from source text. +plugins { + `java-library` +} + +dependencies { + // The released schema artifact, pinned. A fixture that floats to the latest version cannot + // detect a break, because it is always built against the schema it is meant to be testing. + implementation("dev.caskeleton.grpc:grpc-schema-artifact:1.4.0") + implementation("io.grpc:grpc-stub") + implementation("io.grpc:grpc-protobuf") +} diff --git a/src/grpc/grpc-codegen/src/test/resources/consumer-fixtures/v1/src/main/java/fixture/DocumentClientFixture.java b/src/grpc/grpc-codegen/src/test/resources/consumer-fixtures/v1/src/main/java/fixture/DocumentClientFixture.java new file mode 100644 index 00000000..45394dc9 --- /dev/null +++ b/src/grpc/grpc-codegen/src/test/resources/consumer-fixtures/v1/src/main/java/fixture/DocumentClientFixture.java @@ -0,0 +1,33 @@ +package fixture; + +import hyeonworks.document.v1.generated.CreateDocumentRequest; +import hyeonworks.document.v1.generated.DocumentServiceGrpc; +import hyeonworks.document.v1.generated.GetDocumentRequest; + +/** + * A previously released consumer, kept as a fixture rather than as a dependency. + * + *

This file is a test resource, not a compiled source. It records exactly what the v1 client + * named — the generated package it imported from, the service class it referenced and the methods it + * called — so a candidate schema can be checked against a real client's surface instead of against a + * requirement list somebody typed by hand and will forget to update. + * + *

{@code GrpcConsumerFixture.fromJavaSource} derives those three sets from this text. Adding a + * call here widens what a release must keep; that is the intent. + */ +public final class DocumentClientFixture { + + private final DocumentServiceGrpc.DocumentServiceBlockingStub stub; + + public DocumentClientFixture(DocumentServiceGrpc.DocumentServiceBlockingStub stub) { + this.stub = stub; + } + + public String read(String documentId) { + return stub.getDocument(GetDocumentRequest.newBuilder().setId(documentId).build()).getTitle(); + } + + public String create(String title) { + return stub.createDocument(CreateDocumentRequest.newBuilder().setTitle(title).build()).getId(); + } +} diff --git a/src/grpc/grpc-core-api/build.gradle b/src/grpc/grpc-core-api/build.gradle new file mode 100644 index 00000000..f84028d2 --- /dev/null +++ b/src/grpc/grpc-core-api/build.gradle @@ -0,0 +1,11 @@ +apply plugin: 'java-library' + +// The platform's port layer: identifiers, method policy, execution evidence, failure model, +// deadline primitives and request context. +// +// No dependencies at all, and that is the contract rather than an accident. The Stable plan's +// Global Constraints make `grpc-core-api` framework-free so "evidence and policy do not know about +// a transport" is verifiable instead of aspirational — the same rule `messaging-core-api` holds. +// A type here may not name io.grpc, Spring, Netty, protobuf or a database. +dependencies { +} diff --git a/src/grpc/grpc-core-api/gradle.lockfile b/src/grpc/grpc-core-api/gradle.lockfile new file mode 100644 index 00000000..e2c95854 --- /dev/null +++ b/src/grpc/grpc-core-api/gradle.lockfile @@ -0,0 +1,84 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty=compileClasspath,runtimeClasspath diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/context/GrpcClientIdentity.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/context/GrpcClientIdentity.java new file mode 100644 index 00000000..873f6005 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/context/GrpcClientIdentity.java @@ -0,0 +1,55 @@ +package dev.caskeleton.grpc.context; + +/** + * Who is calling, as established by authentication — never as claimed by a header. + * + *

There is no factory that takes raw metadata, and that absence is the design. A tenant id read + * from an inbound header is an assertion by the caller; treating it as identity is how one tenant + * reads another's data. The only way to build this type is to name the authentication source that + * verified it. + */ +public record GrpcClientIdentity(String actorId, String tenantId, String authenticationSource) { + + /** Requires bounded values and a named verifier. */ + public GrpcClientIdentity { + requireBounded(actorId, "actor id"); + requireBounded(tenantId, "tenant id"); + requireBounded(authenticationSource, "authentication source"); + } + + private static void requireBounded(String value, String what) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(what + " must not be blank"); + } + if (value.length() > 128) { + throw new IllegalArgumentException(what + " must be at most 128 characters"); + } + for (int index = 0; index < value.length(); index++) { + if (Character.isISOControl(value.charAt(index))) { + throw new IllegalArgumentException(what + " must not contain a control character"); + } + } + } + + /** + * The only constructor a transport is allowed to reach. + * + * @param authenticationSource the verifier that established this identity — a token issuer, an + * mTLS peer certificate subject, an internal service credential. Naming it is what makes an + * audit able to answer "on what basis did we believe this". + */ + public static GrpcClientIdentity fromVerifiedAuthentication( + String actorId, String tenantId, String authenticationSource) { + return new GrpcClientIdentity(actorId, tenantId, authenticationSource); + } + + /** + * The anonymous caller, for methods that allow one. + * + *

Explicit rather than null, so "no identity" and "identity not yet resolved" are different + * states in the code that reads them. + */ + public static GrpcClientIdentity anonymous() { + return new GrpcClientIdentity("anonymous", "public", "anonymous-access-policy"); + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/context/GrpcMetadataBudget.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/context/GrpcMetadataBudget.java new file mode 100644 index 00000000..1ddc6394 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/context/GrpcMetadataBudget.java @@ -0,0 +1,66 @@ +package dev.caskeleton.grpc.context; + +import java.util.Map; + +/** + * Two budgets, not one: the transport's hard total, and the softer allowance for keys this + * application defines. + * + *

They are separate because they fail differently. Exceeding the hard total is a transport + * rejection that arrives as an opaque {@code RESOURCE_EXHAUSTED} from the framework with no + * indication of which header was to blame; exceeding the user-defined allowance is this platform's + * own decision, made before the call starts, with the offending key in the message. Merging them + * would mean discovering the first kind at the point where only the second kind is fixable. + */ +public record GrpcMetadataBudget(int maxTotalBytes, int maxUserDefinedBytes, int maxEntries) { + + /** Rejects an incoherent budget. */ + public GrpcMetadataBudget { + if (maxTotalBytes <= 0 || maxUserDefinedBytes <= 0 || maxEntries <= 0) { + throw new IllegalArgumentException("every metadata budget bound must be positive"); + } + if (maxUserDefinedBytes > maxTotalBytes) { + throw new IllegalArgumentException( + "user-defined allowance " + + maxUserDefinedBytes + + " cannot exceed the hard total " + + maxTotalBytes); + } + } + + /** The platform default: 8 KiB total, 4 KiB of it user-defined, at most 32 entries. */ + public static GrpcMetadataBudget standard() { + return new GrpcMetadataBudget(8192, 4096, 32); + } + + /** + * Checks a proposed metadata set before the call starts. + * + * @throws IllegalArgumentException naming the bound that was exceeded + */ + public void check(Map metadata) { + if (metadata == null) { + throw new IllegalArgumentException("metadata must not be null"); + } + if (metadata.size() > maxEntries) { + throw new IllegalArgumentException( + "metadata has " + metadata.size() + " entries; the budget allows " + maxEntries); + } + int userDefinedBytes = 0; + for (Map.Entry entry : metadata.entrySet()) { + String value = entry.getValue(); + if (value == null) { + throw new IllegalArgumentException( + "metadata key '" + entry.getKey().name() + "' has a null value"); + } + userDefinedBytes += entry.getKey().name().length() + value.length(); + } + if (userDefinedBytes > maxUserDefinedBytes) { + throw new IllegalArgumentException( + "user-defined metadata is " + + userDefinedBytes + + " bytes; the budget allows " + + maxUserDefinedBytes); + } + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/context/GrpcMetadataKey.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/context/GrpcMetadataKey.java new file mode 100644 index 00000000..a09143a7 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/context/GrpcMetadataKey.java @@ -0,0 +1,65 @@ +package dev.caskeleton.grpc.context; + +import java.util.regex.Pattern; + +/** + * A metadata key the platform is willing to carry. + * + *

The {@code grpc-} prefix is refused outright: it is reserved by the protocol, and a + * user-defined key that borrows it is a key whose meaning changes when the library is upgraded. The + * {@code -bin} suffix rule is not cosmetic either — gRPC decides base64 handling from it, so a + * binary key without the suffix is a value that arrives corrupted rather than one that arrives + * mislabelled. + */ +public record GrpcMetadataKey(String name, Kind kind) { + + /** Whether the value travels as ASCII text or as base64-encoded bytes. */ + public enum Kind { + /** Printable ASCII value. */ + ASCII, + /** Binary value; the key must end in {@code -bin}. */ + BINARY + } + + private static final Pattern CANONICAL = Pattern.compile("[a-z0-9]([a-z0-9._-]*[a-z0-9])?"); + private static final String BINARY_SUFFIX = "-bin"; + private static final String RESERVED_PREFIX = "grpc-"; + + /** Validates the name against the protocol's rules and this platform's allowlist shape. */ + public GrpcMetadataKey { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("metadata key must not be blank"); + } + if (kind == null) { + throw new IllegalArgumentException("metadata key needs a kind"); + } + if (name.length() > 64) { + throw new IllegalArgumentException("metadata key must be at most 64 characters: " + name); + } + if (!CANONICAL.matcher(name).matches()) { + throw new IllegalArgumentException( + "metadata key must be lowercase and use only [a-z0-9._-]: " + name); + } + if (name.startsWith(RESERVED_PREFIX)) { + throw new IllegalArgumentException( + "metadata key '" + name + "' uses the reserved 'grpc-' prefix"); + } + boolean binarySuffix = name.endsWith(BINARY_SUFFIX); + if (kind == Kind.BINARY && !binarySuffix) { + throw new IllegalArgumentException("binary metadata key must end in '-bin': " + name); + } + if (kind == Kind.ASCII && binarySuffix) { + throw new IllegalArgumentException("ASCII metadata key must not end in '-bin': " + name); + } + } + + /** An ASCII key. */ + public static GrpcMetadataKey ascii(String name) { + return new GrpcMetadataKey(name, Kind.ASCII); + } + + /** A binary key. */ + public static GrpcMetadataKey binary(String name) { + return new GrpcMetadataKey(name, Kind.BINARY); + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/context/GrpcRequestContext.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/context/GrpcRequestContext.java new file mode 100644 index 00000000..a0c75d22 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/context/GrpcRequestContext.java @@ -0,0 +1,87 @@ +package dev.caskeleton.grpc.context; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.deadline.GrpcCancellationToken; +import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * What an application use case is handed when a gRPC call reaches it. + * + *

Notice what is not here: no {@code Metadata}, no credentials, no headers map. The metadata + * that survives is the allowlisted subset, keyed by a validated {@link GrpcMetadataKey}, and the + * allowlist is supplied at construction rather than consulted from a global — so a test can state + * the allowlist it means and a second transport cannot quietly widen it. + */ +public record GrpcRequestContext( + GrpcMethodName method, + RpcType rpcType, + GrpcClientIdentity identity, + GrpcDeadlineBudget deadline, + GrpcCancellationToken cancellation, + Map metadata, + Optional traceId) { + + /** Copies the metadata map so the context cannot change after the call has started. */ + public GrpcRequestContext { + if (method == null + || rpcType == null + || identity == null + || deadline == null + || cancellation == null + || metadata == null + || traceId == null) { + throw new IllegalArgumentException("request context fields must all be present"); + } + metadata = Map.copyOf(metadata); + } + + /** + * Builds a context from inbound metadata, keeping only allowlisted keys. + * + *

Unknown keys are refused rather than dropped. Silently dropping is worse: the caller + * believes it sent a correlation id, the server never sees one, and nothing anywhere says so. + * + * @throws IllegalArgumentException when a key is outside {@code allowlist}, or the budget is + * exceeded + */ + public static GrpcRequestContext create( + GrpcMethodName method, + RpcType rpcType, + GrpcClientIdentity identity, + GrpcDeadlineBudget deadline, + GrpcCancellationToken cancellation, + Map inboundMetadata, + Set allowlist, + GrpcMetadataBudget budget, + String traceId) { + if (inboundMetadata == null || allowlist == null || budget == null) { + throw new IllegalArgumentException("metadata, allowlist and budget must all be present"); + } + Map accepted = new LinkedHashMap<>(); + for (Map.Entry entry : inboundMetadata.entrySet()) { + if (!allowlist.contains(entry.getKey())) { + throw new IllegalArgumentException( + "metadata key '" + entry.getKey().name() + "' is not on this method's allowlist"); + } + accepted.put(entry.getKey(), entry.getValue()); + } + budget.check(accepted); + return new GrpcRequestContext( + method, rpcType, identity, deadline, cancellation, accepted, Optional.ofNullable(traceId)); + } + + /** An allowlisted metadata value, if the caller sent one. */ + public Optional metadataValue(GrpcMetadataKey key) { + return Optional.ofNullable(metadata.get(key)); + } + + /** Whether the call is still worth doing work for. */ + public boolean live() { + return !cancellation.cancelled() && !deadline.expired(); + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcChannelProfileName.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcChannelProfileName.java new file mode 100644 index 00000000..10d63f7e --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcChannelProfileName.java @@ -0,0 +1,24 @@ +package dev.caskeleton.grpc.core; + +import java.util.regex.Pattern; + +/** + * The logical name of a client channel profile — {@code document-service-read}, not a host. + * + *

A channel is named for the traffic it carries rather than for where it points, because the + * same backend reached with a long-stream SLO and with a short-unary SLO is two channels. Naming + * them both after the service is what makes that distinction disappear. + */ +public record GrpcChannelProfileName(String value) { + + private static final Pattern CANONICAL = Pattern.compile("[a-z][a-z0-9]*(-[a-z0-9]+)*"); + + /** Validates the canonical lower-kebab form. */ + public GrpcChannelProfileName { + GrpcIdentifiers.requireBounded(value, "channel profile name"); + if (!CANONICAL.matcher(value).matches()) { + throw new IllegalArgumentException( + "channel profile name must be lower-kebab-case; got '" + value + "'"); + } + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcIdentifiers.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcIdentifiers.java new file mode 100644 index 00000000..2fe99149 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcIdentifiers.java @@ -0,0 +1,47 @@ +package dev.caskeleton.grpc.core; + +/** + * Shared validation for the platform's bounded identifiers. + * + *

Package-private machinery rather than a public helper: the rule that an identifier rejects + * blanks and control characters belongs to the identifiers, and a public utility would invite + * callers to validate a string and then carry it as a {@code String} anyway — which is the thing + * the identifier types exist to stop. + */ +final class GrpcIdentifiers { + + private GrpcIdentifiers() {} + + /** Longest identifier this platform accepts, in characters. */ + static final int MAX_LENGTH = 256; + + /** + * Returns {@code value} when it is a well-formed identifier segment. + * + * @throws IllegalArgumentException when it is null, blank, over-long, or carries a control + * character — including the ones that would forge a log line or a metadata frame + */ + static String requireBounded(String value, String what) { + if (value == null) { + throw new IllegalArgumentException(what + " must not be null"); + } + if (value.isBlank()) { + throw new IllegalArgumentException(what + " must not be blank"); + } + if (value.length() > MAX_LENGTH) { + throw new IllegalArgumentException( + what + " must be at most " + MAX_LENGTH + " characters; got " + value.length()); + } + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (Character.isISOControl(character)) { + throw new IllegalArgumentException( + what + " must not contain a control character at index " + index); + } + if (Character.isWhitespace(character)) { + throw new IllegalArgumentException(what + " must not contain whitespace at index " + index); + } + } + return value; + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcMethodName.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcMethodName.java new file mode 100644 index 00000000..1db06d28 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcMethodName.java @@ -0,0 +1,55 @@ +package dev.caskeleton.grpc.core; + +import java.util.regex.Pattern; + +/** + * The canonical full method name, {@code package.Service/Method}. + * + *

This is the identity every other contract keys on: the policy catalog, the descriptor, the + * operation ledger, the retry decision and the observation convention all have to agree on one + * spelling of a method, and this type is that spelling. It is deliberately the same string gRPC + * itself uses for a full method name, so a descriptor and a catalog cannot disagree about which + * method a policy covers. + */ +public record GrpcMethodName(GrpcServiceName service, String method) { + + private static final Pattern METHOD = Pattern.compile("[A-Z][A-Za-z0-9_]*"); + + /** Validates the method half; the service half validates itself. */ + public GrpcMethodName { + if (service == null) { + throw new IllegalArgumentException("method name requires a service"); + } + GrpcIdentifiers.requireBounded(method, "method name"); + if (!METHOD.matcher(method).matches()) { + throw new IllegalArgumentException( + "method must be an UpperCamelCase proto method name; got '" + method + "'"); + } + } + + /** + * Parses {@code package.Service/Method}. + * + * @throws IllegalArgumentException when the canonical separator is missing or repeated — a name + * with two slashes is not a method name with a slash in it, it is a different string + */ + public static GrpcMethodName parse(String canonical) { + GrpcIdentifiers.requireBounded(canonical, "full method name"); + int separator = canonical.indexOf('/'); + if (separator < 0) { + throw new IllegalArgumentException( + "full method name must be 'package.Service/Method'; got '" + canonical + "'"); + } + if (canonical.indexOf('/', separator + 1) >= 0) { + throw new IllegalArgumentException( + "full method name must contain exactly one '/'; got '" + canonical + "'"); + } + return new GrpcMethodName( + new GrpcServiceName(canonical.substring(0, separator)), canonical.substring(separator + 1)); + } + + /** The canonical {@code package.Service/Method} form. */ + public String canonical() { + return service.value() + "/" + method; + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcServiceName.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcServiceName.java new file mode 100644 index 00000000..6a44d227 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcServiceName.java @@ -0,0 +1,37 @@ +package dev.caskeleton.grpc.core; + +import java.util.regex.Pattern; + +/** + * A fully qualified Protobuf service name — {@code package.subpackage.ServiceName}. + * + *

Bounded on purpose. Every downstream consumer of an identifier in this platform — the policy + * catalog, the descriptor, the observability convention, the admin snapshot — assumes the name is + * canonical, and the only place that assumption can be established is where the name is made. + */ +public record GrpcServiceName(String value) { + + private static final Pattern CANONICAL = + Pattern.compile("[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*\\.[A-Z][A-Za-z0-9_]*"); + + /** Validates the canonical form. */ + public GrpcServiceName { + GrpcIdentifiers.requireBounded(value, "service name"); + if (!CANONICAL.matcher(value).matches()) { + throw new IllegalArgumentException( + "service name must be a fully qualified proto name 'package.Service'; got '" + + value + + "'"); + } + } + + /** The proto package this service is declared in. */ + public String protoPackage() { + return value.substring(0, value.lastIndexOf('.')); + } + + /** The unqualified service name. */ + public String simpleName() { + return value.substring(value.lastIndexOf('.') + 1); + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcStableBuildInvariant.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcStableBuildInvariant.java new file mode 100644 index 00000000..d86794a6 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcStableBuildInvariant.java @@ -0,0 +1,53 @@ +package dev.caskeleton.grpc.core; + +import java.util.Set; + +/** + * Build-time invariants the Stable platform holds about its own module graph. + * + *

These are asserted from Java as well as from Gradle on purpose. The registry gate ({@code + * verifyCleanArchitectureDependencies}) is the one that fails a build, and it is the authority; + * this class is what a runtime component calls when it is handed a dependency set it did not + * compile against — a starter reading its own classpath, say — where the Gradle gate has nothing to + * look at. + */ +public final class GrpcStableBuildInvariant { + + private GrpcStableBuildInvariant() {} + + /** + * Whether a Stable module may depend on an Advanced one. + * + *

Always false, and it takes no argument for a reason: the answer does not vary by module, by + * capability or by environment. A method that could return true for some input would be the seam + * through which "just this one Advanced type in the starter" arrives. + */ + public static boolean advancedDependencyAllowed() { + return false; + } + + /** + * Fails when a Stable module's dependency set reaches an Advanced module. + * + * @throws IllegalStateException naming every advanced dependency found + */ + public static void requireNoAdvancedDependency(String moduleId, Set dependencies) { + GrpcIdentifiers.requireBounded(moduleId, "module id"); + if (dependencies == null) { + throw new IllegalArgumentException("dependency set must not be null"); + } + if (!GrpcStableModuleCatalog.standard().isStable(moduleId)) { + return; + } + Set leaks = GrpcStableModuleCatalog.advancedLeaks(dependencies); + if (!leaks.isEmpty()) { + throw new IllegalStateException( + "Stable module '" + + moduleId + + "' must not depend on advanced modules " + + leaks.stream().sorted().toList() + + "; advanced capabilities are reached through an explicit feature flag, never by " + + "being on the Stable classpath."); + } + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcStableModuleCatalog.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcStableModuleCatalog.java new file mode 100644 index 00000000..b301a75e --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcStableModuleCatalog.java @@ -0,0 +1,79 @@ +package dev.caskeleton.grpc.core; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * The exact set of Stable platform modules, and the exact set that is not Stable. + * + *

The Stable plan fixes its module list as a Global Constraint so that "this capability is + * Stable" is a fact with one owner instead of a property of whatever happens to be on a classpath. + * This repository's module registry ({@code src/config/architecture/modules.json}) is the SSOT for + * which Gradle projects exist; this catalog is the SSOT for which of them the Stable contract + * covers, and {@code GrpcStableModuleCatalogTest} holds the two together. + * + *

The names are this repository's leaf ids rather than the plan's, because the plan's four + * testkit modules are four strict test lanes here — see the adaptation design, §2. + */ +public record GrpcStableModuleCatalog(Set modules) { + + private static final Set STABLE = + Set.of( + "grpc-core-api", + "grpc-proto-contract", + "grpc-codegen", + "grpc-policy", + "grpc-server", + "grpc-client", + "grpc-discovery", + "grpc-admin", + "grpc-observability", + "grpc-operation-ledger-jpa", + "grpc-spring-boot-starter", + "grpc-testkit"); + + private static final Set ADVANCED = + Set.of( + "grpc-advanced-bootstrap", + "grpc-advanced-edition", + "grpc-advanced-streaming", + "grpc-advanced-resilience", + "grpc-advanced-compat", + "grpc-advanced-diagnostics"); + + /** Defensively copies, so a caller cannot widen the Stable set by mutating what it was given. */ + public GrpcStableModuleCatalog { + if (modules == null || modules.isEmpty()) { + throw new IllegalArgumentException("a module catalog needs at least one module"); + } + modules = Set.copyOf(modules); + } + + /** The Stable module set. */ + public static GrpcStableModuleCatalog standard() { + return new GrpcStableModuleCatalog(STABLE); + } + + /** The Advanced and Experimental module set, which is disjoint from {@link #standard()}. */ + public static Set advancedModules() { + return ADVANCED; + } + + /** Whether {@code moduleId} is part of the Stable contract. */ + public boolean isStable(String moduleId) { + return modules.contains(moduleId); + } + + /** + * The advanced module ids that appear in {@code candidateDependencies}. + * + *

Returned rather than thrown so a build or startup check can name every violation at once. A + * gate that reports the first advanced dependency it finds makes removing four of them a + * four-round conversation. + */ + public static Set advancedLeaks(Set candidateDependencies) { + Set leaks = new LinkedHashSet<>(candidateDependencies); + leaks.retainAll(ADVANCED); + return Set.copyOf(leaks); + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcStatusCode.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcStatusCode.java new file mode 100644 index 00000000..23e44d9f --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/GrpcStatusCode.java @@ -0,0 +1,52 @@ +package dev.caskeleton.grpc.core; + +/** + * The canonical gRPC status codes, mirrored so that {@code grpc-core-api} stays free of io.grpc. + * + *

The mirror is the point. The Stable plan requires the evidence and failure model to be + * framework-free, and a failure context that names {@code io.grpc.Status} would put the transport + * inside the contract that exists to describe what the transport did. {@code grpc-policy} owns the + * translation in both directions and is tested against the real enum. + */ +public enum GrpcStatusCode { + OK(0), + CANCELLED(1), + UNKNOWN(2), + INVALID_ARGUMENT(3), + DEADLINE_EXCEEDED(4), + NOT_FOUND(5), + ALREADY_EXISTS(6), + PERMISSION_DENIED(7), + RESOURCE_EXHAUSTED(8), + FAILED_PRECONDITION(9), + ABORTED(10), + OUT_OF_RANGE(11), + UNIMPLEMENTED(12), + INTERNAL(13), + UNAVAILABLE(14), + DATA_LOSS(15), + UNAUTHENTICATED(16); + + private final int value; + + GrpcStatusCode(int value) { + this.value = value; + } + + /** The wire value, identical to {@code io.grpc.Status.Code#value()}. */ + public int value() { + return value; + } + + /** + * Whether this code, on its own, says the server never began the work. + * + *

Only {@link #UNAVAILABLE} at connect time and {@link #CANCELLED} before send have that + * meaning, and neither is decidable from the code alone — which is why this returns false for + * every code. It exists so a caller that wants to ask "is this safe to replay" is pushed to the + * evidence model instead of reading safety out of a status. + */ + public boolean provesNotStarted() { + return false; + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/RpcType.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/RpcType.java new file mode 100644 index 00000000..684bba20 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/core/RpcType.java @@ -0,0 +1,53 @@ +package dev.caskeleton.grpc.core; + +/** + * The four gRPC method shapes. + * + *

Only {@link #UNARY} and {@link #SERVER_STREAMING} are Stable. Client and bidirectional + * streaming are Advanced capabilities: they exist in this enum because a descriptor can name them + * and a policy has to be able to refuse them, not because the Stable platform serves them. + */ +public enum RpcType { + /** One request, one response. */ + UNARY(false, false), + /** One request, a bounded stream of responses. */ + SERVER_STREAMING(false, true), + /** A stream of requests, one response. Advanced. */ + CLIENT_STREAMING(true, false), + /** Streams in both directions. Advanced. */ + BIDI_STREAMING(true, true); + + private final boolean clientStreaming; + private final boolean serverStreaming; + + RpcType(boolean clientStreaming, boolean serverStreaming) { + this.clientStreaming = clientStreaming; + this.serverStreaming = serverStreaming; + } + + /** Whether the client sends more than one message. */ + public boolean clientStreaming() { + return clientStreaming; + } + + /** Whether the server sends more than one message. */ + public boolean serverStreaming() { + return serverStreaming; + } + + /** Whether either direction streams. */ + public boolean streaming() { + return clientStreaming || serverStreaming; + } + + /** + * Whether the Stable platform serves this shape. + * + *

The Stable scope is Unary and Server Streaming. A Stable component that is handed anything + * else must refuse it rather than degrade: partially supporting client streaming is how a + * platform ends up with a delivery guarantee nobody wrote down. + */ + public boolean stable() { + return !clientStreaming; + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/deadline/GrpcCancellationToken.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/deadline/GrpcCancellationToken.java new file mode 100644 index 00000000..534f3400 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/deadline/GrpcCancellationToken.java @@ -0,0 +1,68 @@ +package dev.caskeleton.grpc.deadline; + +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +/** + * An idempotent cancellation signal that remembers why it first fired. + * + *

"First reason wins" is the contract that makes a cancellation report usable. A call cancelled + * by its client and then, a millisecond later, by a shutdown drain has one cause and one useful + * story; a token that keeps the last write tells the second one. + */ +public final class GrpcCancellationToken { + + private final AtomicReference cancellation = new AtomicReference<>(); + + /** The reason and moment a token fired. */ + public record Cancellation(String reason, Instant at) { + /** Requires a bounded reason and a moment. */ + public Cancellation { + if (reason == null || reason.isBlank() || reason.length() > 128) { + throw new IllegalArgumentException( + "cancellation reason must be a bounded non-blank string"); + } + if (at == null) { + throw new IllegalArgumentException("cancellation needs a timestamp"); + } + } + } + + /** + * Cancels once. Later calls are accepted and ignored. + * + * @return true when this call is the one that cancelled the token + */ + public boolean cancel(String reason, Instant at) { + return cancellation.compareAndSet(null, new Cancellation(reason, at)); + } + + /** Whether the token has fired. */ + public boolean cancelled() { + return cancellation.get() != null; + } + + /** The first cancellation, if any. */ + public Optional cancellation() { + return Optional.ofNullable(cancellation.get()); + } + + /** + * Fails when the token has already fired. + * + *

Called before starting a new external side effect. The Stable plan forbids beginning one + * after cancellation, and the only way to hold that is to ask at the moment of starting. + */ + public void requireNotCancelled(String operation) { + Cancellation current = cancellation.get(); + if (current != null) { + throw new IllegalStateException( + "refusing to start '" + + operation + + "': the call was cancelled (" + + current.reason() + + ")"); + } + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/deadline/GrpcDeadlineBudget.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/deadline/GrpcDeadlineBudget.java new file mode 100644 index 00000000..f883ab46 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/deadline/GrpcDeadlineBudget.java @@ -0,0 +1,85 @@ +package dev.caskeleton.grpc.deadline; + +import java.time.Duration; + +/** + * How much time a call actually has, after the parent deadline and the method profile have both had + * their say. + * + *

The rule is "shorter of the two, minus the reserve", and it is here rather than at each call + * site because the failure mode of getting it wrong is silent: a downstream timeout longer than the + * inbound deadline never fires, and the caller sees a deadline the server has no idea it missed. + */ +public record GrpcDeadlineBudget(Duration remaining, GrpcDeadlineProfile profile) { + + /** Rejects a budget with no time in it. */ + public GrpcDeadlineBudget { + if (remaining == null || profile == null) { + throw new IllegalArgumentException("a deadline budget needs a remaining time and a profile"); + } + if (remaining.isNegative()) { + throw new IllegalArgumentException("remaining time must not be negative; got " + remaining); + } + } + + /** + * The budget for a Stable unary entry point, which must have a positive deadline. + * + * @throws IllegalArgumentException when there is no parent deadline at all — the Stable plan + * requires every unary method to carry one, and defaulting here is how a call ends up with an + * infinite deadline nobody chose + */ + public static GrpcDeadlineBudget forEntryPoint( + Duration parentRemaining, GrpcDeadlineProfile profile) { + if (profile == null) { + throw new IllegalArgumentException("a deadline budget needs a profile"); + } + if (parentRemaining == null) { + throw new IllegalArgumentException( + "a Stable unary call requires a positive deadline; none was propagated"); + } + if (parentRemaining.isZero() || parentRemaining.isNegative()) { + throw new IllegalArgumentException( + "a Stable unary call requires a positive deadline; got " + parentRemaining); + } + Duration bounded = + parentRemaining.compareTo(profile.total()) <= 0 ? parentRemaining : profile.total(); + Duration afterReserve = bounded.minus(profile.safetyMargin()); + return new GrpcDeadlineBudget( + afterReserve.isNegative() ? Duration.ZERO : afterReserve, profile); + } + + /** + * Derives the budget for a downstream dependency call under {@code downstream}. + * + *

Takes the shorter of what is left here and what the downstream profile allows, then + * subtracts the downstream reserve. A dependency can never be given more time than its caller + * has. + */ + public GrpcDeadlineBudget deriveDownstream(GrpcDeadlineProfile downstream) { + if (downstream == null) { + throw new IllegalArgumentException("a downstream budget needs a profile"); + } + Duration bounded = + remaining.compareTo(downstream.total()) <= 0 ? remaining : downstream.total(); + Duration afterReserve = bounded.minus(downstream.safetyMargin()); + return new GrpcDeadlineBudget( + afterReserve.isNegative() ? Duration.ZERO : afterReserve, downstream); + } + + /** + * Whether there is enough time left to be worth starting a dependency call. + * + *

Starting one with less is not merely wasteful: it produces a deadline failure on the + * dependency that looks identical to the dependency being slow, and the resulting incident + * investigates the wrong system. + */ + public boolean canStartDependencyCall() { + return remaining.compareTo(profile.minimumAttemptBudget()) >= 0 && !remaining.isZero(); + } + + /** Whether any time is left at all. */ + public boolean expired() { + return remaining.isZero() || remaining.isNegative(); + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/deadline/GrpcDeadlineExceededException.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/deadline/GrpcDeadlineExceededException.java new file mode 100644 index 00000000..e65e5a0e --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/deadline/GrpcDeadlineExceededException.java @@ -0,0 +1,26 @@ +package dev.caskeleton.grpc.deadline; + +import dev.caskeleton.grpc.error.GrpcFailureContext; +import dev.caskeleton.grpc.error.GrpcPlatformException; + +/** + * A deadline elapsed. + * + *

Its own type rather than a generic platform exception because callers branch on it, and + * because it is the failure most often misread: for a mutation it is a statement about time, not + * about the outcome. {@link #requiresReconciliation()} answers the question the status code cannot. + */ +public class GrpcDeadlineExceededException extends GrpcPlatformException { + + private static final long serialVersionUID = 1L; + + /** Wraps a deadline failure context. */ + public GrpcDeadlineExceededException(GrpcFailureContext context) { + super(context); + } + + /** Wraps a deadline failure context, preserving the underlying cause. */ + public GrpcDeadlineExceededException(GrpcFailureContext context, Throwable cause) { + super(context, cause); + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/deadline/GrpcDeadlineProfile.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/deadline/GrpcDeadlineProfile.java new file mode 100644 index 00000000..4c6308d8 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/deadline/GrpcDeadlineProfile.java @@ -0,0 +1,59 @@ +package dev.caskeleton.grpc.deadline; + +import java.time.Duration; + +/** + * A method's deadline shape: how long it gets, how much is held back, and the smallest slice worth + * starting a dependency call with. + * + *

The safety margin is not decoration. A downstream call handed the parent's entire remaining + * time has no room left to serialize a response or write trailers, so the caller's deadline fires + * while the answer is in flight — which produces exactly the {@code COMPLETION_UNKNOWN} the + * platform then has to reconcile. + */ +public record GrpcDeadlineProfile( + Duration total, Duration safetyMargin, Duration minimumAttemptBudget) { + + /** Rejects a profile that cannot leave usable time behind. */ + public GrpcDeadlineProfile { + if (total == null || safetyMargin == null || minimumAttemptBudget == null) { + throw new IllegalArgumentException("a deadline profile needs all three durations"); + } + if (total.isZero() || total.isNegative()) { + throw new IllegalArgumentException("total deadline must be positive; got " + total); + } + if (safetyMargin.isNegative()) { + throw new IllegalArgumentException("safety margin must not be negative"); + } + if (minimumAttemptBudget.isNegative()) { + throw new IllegalArgumentException("minimum attempt budget must not be negative"); + } + if (safetyMargin.compareTo(total) >= 0) { + throw new IllegalArgumentException( + "safety margin " + safetyMargin + " leaves no time inside total " + total); + } + if (minimumAttemptBudget.compareTo(total.minus(safetyMargin)) > 0) { + throw new IllegalArgumentException( + "minimum attempt budget " + + minimumAttemptBudget + + " exceeds the usable window " + + total.minus(safetyMargin)); + } + } + + /** + * A profile with the platform's default reserves: a tenth of the total held back for response + * serialization and trailers, and a twentieth as the smallest attempt worth starting. + */ + public static GrpcDeadlineProfile of(Duration total) { + if (total == null || total.isZero() || total.isNegative()) { + throw new IllegalArgumentException("total deadline must be positive"); + } + return new GrpcDeadlineProfile(total, total.dividedBy(10), total.dividedBy(20)); + } + + /** Total minus the reserve — the time actually available to the call. */ + public Duration usable() { + return total.minus(safetyMargin); + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/error/GrpcCompletionOutcome.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/error/GrpcCompletionOutcome.java new file mode 100644 index 00000000..c5e0b35f --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/error/GrpcCompletionOutcome.java @@ -0,0 +1,69 @@ +package dev.caskeleton.grpc.error; + +import dev.caskeleton.grpc.core.GrpcStatusCode; +import dev.caskeleton.grpc.evidence.GrpcBusinessEvidence; +import dev.caskeleton.grpc.evidence.GrpcExecutionEvidence; +import dev.caskeleton.grpc.evidence.GrpcStreamEvidence; + +/** + * What the caller may conclude about the business operation. + * + *

Separate from the status code because the interesting case has no status of its own: a + * mutation that times out is {@code DEADLINE_EXCEEDED} on the wire and {@link #COMPLETION_UNKNOWN} + * in the business, and a caller that reads the first as the second's answer either loses a + * committed write or performs it twice. + */ +public enum GrpcCompletionOutcome { + /** The operation completed and the caller knows it. */ + COMPLETED, + /** The operation did not run; nothing changed. */ + REJECTED, + /** The operation may or may not have completed. Requires a status query or reconciliation. */ + COMPLETION_UNKNOWN, + /** A stream delivered a prefix and then ended. */ + PARTIAL_STREAM; + + /** + * Derives the outcome from a status and the evidence, for a call that changes state. + * + *

{@code DEADLINE_EXCEEDED} on a mutation defaults to {@link #COMPLETION_UNKNOWN} rather than + * to a failure, and {@code UNAVAILABLE} does the same unless the client watched the send fail. + * Those two defaults are the plan's Global Constraints, in the one place that can apply them. + */ + public static GrpcCompletionOutcome forMutation( + GrpcStatusCode statusCode, GrpcExecutionEvidence evidence) { + if (statusCode == null || evidence == null) { + throw new IllegalArgumentException("outcome derivation needs a status and evidence"); + } + if (evidence.business() == GrpcBusinessEvidence.COMMIT_CONFIRMED) { + return COMPLETED; + } + if (evidence.stream() instanceof GrpcStreamEvidence.Partial) { + return PARTIAL_STREAM; + } + if (statusCode == GrpcStatusCode.OK) { + return COMPLETED; + } + if (evidence.transport().provesNotStarted()) { + return REJECTED; + } + return switch (statusCode) { + case DEADLINE_EXCEEDED, UNAVAILABLE, CANCELLED, UNKNOWN, INTERNAL -> COMPLETION_UNKNOWN; + case INVALID_ARGUMENT, + UNAUTHENTICATED, + PERMISSION_DENIED, + NOT_FOUND, + FAILED_PRECONDITION, + OUT_OF_RANGE, + UNIMPLEMENTED, + RESOURCE_EXHAUSTED -> + REJECTED; + case OK, ALREADY_EXISTS, ABORTED, DATA_LOSS -> COMPLETION_UNKNOWN; + }; + } + + /** Whether the caller must resolve the result before acting on it. */ + public boolean requiresReconciliation() { + return this == COMPLETION_UNKNOWN; + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/error/GrpcFailureCategory.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/error/GrpcFailureCategory.java new file mode 100644 index 00000000..a3690c1c --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/error/GrpcFailureCategory.java @@ -0,0 +1,64 @@ +package dev.caskeleton.grpc.error; + +import dev.caskeleton.grpc.core.GrpcStatusCode; + +/** + * The platform's stable failure taxonomy, and the canonical status each category maps to. + * + *

The mapping lives on the category rather than in a switch somewhere in the server, because the + * client needs the same table to interpret what it received. A category whose status is decided at + * the mapping site has a different answer on each side of the wire. + */ +public enum GrpcFailureCategory { + /** Malformed request; the transport validator refused it. */ + VALIDATION(GrpcStatusCode.INVALID_ARGUMENT, false), + /** No verified caller identity. */ + AUTHENTICATION(GrpcStatusCode.UNAUTHENTICATED, false), + /** A verified caller without the required permission. */ + AUTHORIZATION(GrpcStatusCode.PERMISSION_DENIED, false), + /** The addressed resource does not exist. */ + NOT_FOUND(GrpcStatusCode.NOT_FOUND, false), + /** A uniqueness or version conflict the caller can resolve. */ + CONFLICT(GrpcStatusCode.ABORTED, true), + /** The resource is in a state that forbids the operation. */ + PRECONDITION(GrpcStatusCode.FAILED_PRECONDITION, false), + /** A quota, rate limit or admission bound was hit. */ + RESOURCE_EXHAUSTED(GrpcStatusCode.RESOURCE_EXHAUSTED, true), + /** The deadline elapsed. Says nothing about whether the work completed. */ + DEADLINE(GrpcStatusCode.DEADLINE_EXCEEDED, false), + /** The caller or the platform cancelled the call. */ + CANCELLED(GrpcStatusCode.CANCELLED, false), + /** The dependency could not be reached, or refused a connection. */ + TRANSIENT_DEPENDENCY(GrpcStatusCode.UNAVAILABLE, true), + /** The dependency answered with a failure that retrying will reproduce. */ + PERMANENT_DEPENDENCY(GrpcStatusCode.INTERNAL, false), + /** The method exists in the schema but is not served. */ + UNIMPLEMENTED(GrpcStatusCode.UNIMPLEMENTED, false), + /** Stored state is inconsistent with what the contract guarantees. */ + DATA_INTEGRITY(GrpcStatusCode.DATA_LOSS, false), + /** Anything unclassified. Never carries provider detail to the client. */ + INTERNAL(GrpcStatusCode.INTERNAL, false); + + private final GrpcStatusCode statusCode; + private final boolean transientFailure; + + GrpcFailureCategory(GrpcStatusCode statusCode, boolean transientFailure) { + this.statusCode = statusCode; + this.transientFailure = transientFailure; + } + + /** The wire status this category maps to. */ + public GrpcStatusCode statusCode() { + return statusCode; + } + + /** + * Whether the same call, later, might succeed. + * + *

Necessary for a retry, never sufficient: {@code TRANSIENT_DEPENDENCY} on a non-idempotent + * mutation is still not retryable, and that decision belongs to the retry coordinator. + */ + public boolean transientFailure() { + return transientFailure; + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/error/GrpcFailureContext.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/error/GrpcFailureContext.java new file mode 100644 index 00000000..110f9bba --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/error/GrpcFailureContext.java @@ -0,0 +1,99 @@ +package dev.caskeleton.grpc.error; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.GrpcStatusCode; +import dev.caskeleton.grpc.evidence.GrpcExecutionEvidence; +import java.time.Duration; +import java.util.Optional; + +/** + * Everything the platform is willing to say about a failed attempt. + * + *

The field list is a closed allowlist rather than a convenience bag. Every field here is + * bounded and safe to log; a payload, a metadata map, a token or an idempotency key has no + * component to live in, so redaction is a property of the type rather than of whoever writes the + * log line. The provider exception survives as {@code cause} on {@link GrpcPlatformException}, + * where it reaches an operator without reaching a client. + */ +public record GrpcFailureContext( + GrpcMethodName method, + GrpcStatusCode statusCode, + GrpcFailureCategory category, + GrpcExecutionEvidence evidence, + GrpcCompletionOutcome completionOutcome, + RetryDisposition retryDisposition, + int attempt, + Duration elapsed, + Optional traceId) { + + /** + * What the platform decided about retrying this attempt, and why it is not re-derivable later. + */ + public enum RetryDisposition { + /** The attempt may be retried under the method's retry policy. */ + RETRYABLE, + /** The attempt must not be retried; repeating it risks a duplicate effect. */ + NOT_RETRYABLE, + /** Retrying is pointless: the same call will fail the same way. */ + TERMINAL, + /** The result is unknown; resolve it with a status query before deciding. */ + RESOLVE_FIRST + } + + /** Validates bounds and rejects a trace id that is not safe to log. */ + public GrpcFailureContext { + if (method == null + || statusCode == null + || category == null + || evidence == null + || completionOutcome == null + || retryDisposition == null + || elapsed == null + || traceId == null) { + throw new IllegalArgumentException("failure context fields must all be present"); + } + if (attempt < 1) { + throw new IllegalArgumentException("attempt is 1-based; got " + attempt); + } + if (elapsed.isNegative()) { + throw new IllegalArgumentException("elapsed must not be negative"); + } + if (!method.equals(evidence.method())) { + throw new IllegalArgumentException( + "failure context method '" + + method.canonical() + + "' disagrees with its evidence '" + + evidence.method().canonical() + + "'"); + } + traceId.ifPresent( + value -> { + if (value.isBlank() || value.length() > 128) { + throw new IllegalArgumentException("trace id must be a bounded non-blank identifier"); + } + }); + } + + /** + * A one-line summary safe to put in an exception message, a log line or an operator report. + * + *

Built here rather than at each call site so there is exactly one answer to "what may this + * failure say out loud". + */ + public String redactedSummary() { + return method.canonical() + + " failed with " + + statusCode + + " (" + + category + + "), outcome=" + + completionOutcome + + ", retry=" + + retryDisposition + + ", attempt=" + + attempt + + ", elapsedMs=" + + elapsed.toMillis() + + traceId.map(id -> ", trace=" + id).orElse(""); + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/error/GrpcPlatformException.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/error/GrpcPlatformException.java new file mode 100644 index 00000000..cae93dcf --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/error/GrpcPlatformException.java @@ -0,0 +1,44 @@ +package dev.caskeleton.grpc.error; + +/** + * The platform's failure carrier. + * + *

Its message is {@link GrpcFailureContext#redactedSummary()} and nothing else, so the string + * that ends up in a log or in a status description cannot accidentally be a driver message + * containing a SQL state, a host or a token. The original exception is kept as the cause, which is + * where an operator reads it and a client does not. + */ +public class GrpcPlatformException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient GrpcFailureContext context; + + /** Wraps a failure context. */ + public GrpcPlatformException(GrpcFailureContext context) { + this(context, null); + } + + /** Wraps a failure context, preserving the provider exception as the cause. */ + public GrpcPlatformException(GrpcFailureContext context, Throwable cause) { + super(requireContext(context).redactedSummary(), cause); + this.context = context; + } + + private static GrpcFailureContext requireContext(GrpcFailureContext context) { + if (context == null) { + throw new IllegalArgumentException("a platform exception requires a failure context"); + } + return context; + } + + /** The structured failure this exception carries. */ + public GrpcFailureContext context() { + return context; + } + + /** Whether the caller must resolve the business result before acting. */ + public boolean requiresReconciliation() { + return context.completionOutcome().requiresReconciliation(); + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/evidence/GrpcBusinessEvidence.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/evidence/GrpcBusinessEvidence.java new file mode 100644 index 00000000..a6b8791c --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/evidence/GrpcBusinessEvidence.java @@ -0,0 +1,31 @@ +package dev.caskeleton.grpc.evidence; + +/** + * What is known about the business effect — the second evidence axis. + * + *

Kept separate from the transport axis because the two genuinely disagree in the case that + * matters: a mutation whose transaction committed and whose response was lost is {@link + * #COMMIT_CONFIRMED} at the server and {@link #COMMIT_UNKNOWN} at the client, and no status code + * distinguishes those from a mutation that never ran. + */ +public enum GrpcBusinessEvidence { + /** No business work was attempted. */ + NONE, + /** The server accepted the call and began application work. */ + ATTEMPTED, + /** The application confirmed a durable commit. */ + COMMIT_CONFIRMED, + /** Work may or may not have committed. This is a real state, not a placeholder. */ + COMMIT_UNKNOWN, + /** The application refused the request; nothing was committed. */ + REJECTED; + + /** + * Whether a retry may run the business operation again without an idempotency guard. + * + *

{@link #COMMIT_UNKNOWN} answers false — which is the whole reason the value exists. + */ + public boolean safeToRepeatWithoutGuard() { + return this == NONE || this == REJECTED; + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/evidence/GrpcExecutionEvidence.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/evidence/GrpcExecutionEvidence.java new file mode 100644 index 00000000..2bbd1b76 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/evidence/GrpcExecutionEvidence.java @@ -0,0 +1,87 @@ +package dev.caskeleton.grpc.evidence; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; + +/** + * The three evidence axes for one RPC attempt, held together and never collapsed. + * + *

The same type is used by the failure model and by the observation convention. That is + * deliberate: when the exception and the metric are built from different snapshots of what + * happened, the incident review has two accounts of one call and no way to choose between them. + */ +public record GrpcExecutionEvidence( + GrpcMethodName method, + RpcType rpcType, + GrpcTransportEvidence transport, + GrpcBusinessEvidence business, + GrpcStreamEvidence stream) { + + /** Rejects combinations that cannot have been observed. */ + public GrpcExecutionEvidence { + if (method == null) { + throw new IllegalArgumentException("evidence requires a method"); + } + if (rpcType == null || transport == null || business == null || stream == null) { + throw new IllegalArgumentException("every evidence axis must be present"); + } + if (rpcType == RpcType.UNARY && stream.delivered()) { + throw new IllegalArgumentException( + "a unary call cannot carry stream evidence; got " + stream); + } + if (transport == GrpcTransportEvidence.NOT_SENT && business != GrpcBusinessEvidence.NONE) { + throw new IllegalArgumentException( + "a request the client never sent cannot carry business evidence " + business); + } + } + + /** A not-yet-started attempt. */ + public static GrpcExecutionEvidence notStarted(GrpcMethodName method, RpcType rpcType) { + return new GrpcExecutionEvidence( + method, + rpcType, + GrpcTransportEvidence.NOT_SENT, + GrpcBusinessEvidence.NONE, + GrpcStreamEvidence.none()); + } + + /** + * Records that response headers arrived, leaving the business axis exactly as it was. + * + *

This is the promotion the plan forbids, written as the one method that is allowed to observe + * headers — so the forbidden edit is visible as a change to this method rather than as a + * plausible line somewhere in an interceptor. + */ + public GrpcExecutionEvidence withResponseHeadersSeen() { + return new GrpcExecutionEvidence( + method, rpcType, GrpcTransportEvidence.RESPONSE_HEADERS_SEEN, business, stream); + } + + /** Records a confirmed commit, which only the application may assert. */ + public GrpcExecutionEvidence withCommitConfirmed() { + return new GrpcExecutionEvidence( + method, rpcType, transport, GrpcBusinessEvidence.COMMIT_CONFIRMED, stream); + } + + /** Records that the outcome of the business work is unknown. */ + public GrpcExecutionEvidence withCommitUnknown() { + return new GrpcExecutionEvidence( + method, rpcType, transport, GrpcBusinessEvidence.COMMIT_UNKNOWN, stream); + } + + /** Replaces the stream axis. */ + public GrpcExecutionEvidence withStream(GrpcStreamEvidence replacement) { + return new GrpcExecutionEvidence(method, rpcType, transport, business, replacement); + } + + /** + * Whether a whole-call transparent retry is permitted by the evidence alone. + * + *

Evidence alone is never sufficient — the idempotency profile and the retry owner also have a + * say — but it is sufficient to refuse, and refusing here keeps the three reasons to refuse from + * having to be re-derived at every call site. + */ + public boolean permitsWholeCallRetry() { + return !stream.delivered() && business.safeToRepeatWithoutGuard(); + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/evidence/GrpcStreamEvidence.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/evidence/GrpcStreamEvidence.java new file mode 100644 index 00000000..f827a98e --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/evidence/GrpcStreamEvidence.java @@ -0,0 +1,80 @@ +package dev.caskeleton.grpc.evidence; + +/** + * What a stream delivered — the third evidence axis. + * + *

A sealed hierarchy rather than an enum because three of the four states carry a position, and + * a position is the only thing that makes stream evidence actionable: "partial" without a last + * sequence cannot be resumed and cannot be reconciled, so it degrades to "something happened". + */ +public sealed interface GrpcStreamEvidence { + + /** No stream was involved. The only legal value for a unary call. */ + record None() implements GrpcStreamEvidence {} + + /** + * The stream delivered messages up to {@code lastSequence} and then ended without completing. + * + *

Its presence forbids a whole-call transparent retry: the client already saw a prefix, and + * replaying the call would deliver that prefix twice. + */ + record Partial(long lastSequence) implements GrpcStreamEvidence { + /** Rejects a sequence that cannot have been observed. */ + public Partial { + if (lastSequence < 0) { + throw new IllegalArgumentException("last sequence must not be negative"); + } + } + } + + /** + * The consumer acknowledged applying messages up to {@code lastAppliedSequence}. + * + *

Requires a named acknowledgement source. The Stable plan forbids synthesising this state + * from transport progress, and a required non-blank source is what makes that forgery visible: + * there is nowhere to put "the writer returned from onNext". + */ + record Applied(long lastAppliedSequence, String acknowledgementSource) + implements GrpcStreamEvidence { + /** Requires a real acknowledgement source. */ + public Applied { + if (lastAppliedSequence < 0) { + throw new IllegalArgumentException("last applied sequence must not be negative"); + } + if (acknowledgementSource == null || acknowledgementSource.isBlank()) { + throw new IllegalArgumentException( + "APPLIED requires a named application acknowledgement source; transport progress is " + + "not an acknowledgement"); + } + } + } + + /** + * The stream ended at {@code lastSequence} and can be continued with {@code + * resumeTokenReference}. + * + *

The reference is an opaque handle, never the token itself: a resume token carries a signed + * actor, tenant and filter fingerprint, and evidence is a thing that gets logged. + */ + record Resumable(long lastSequence, String resumeTokenReference) implements GrpcStreamEvidence { + /** Requires a position and an opaque handle. */ + public Resumable { + if (lastSequence < 0) { + throw new IllegalArgumentException("last sequence must not be negative"); + } + if (resumeTokenReference == null || resumeTokenReference.isBlank()) { + throw new IllegalArgumentException("a resumable stream needs a resume token reference"); + } + } + } + + /** The canonical "no stream" value. */ + static GrpcStreamEvidence none() { + return new None(); + } + + /** Whether any message was delivered, which is what forbids a whole-call retry. */ + default boolean delivered() { + return !(this instanceof None); + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/evidence/GrpcTransportEvidence.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/evidence/GrpcTransportEvidence.java new file mode 100644 index 00000000..c9863699 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/evidence/GrpcTransportEvidence.java @@ -0,0 +1,41 @@ +package dev.caskeleton.grpc.evidence; + +/** + * What the transport was observed to do — the first of the three independent evidence axes. + * + *

Every value here is an observation, never an inference. The distinction that matters is + * between {@link #NOT_SENT}, which means the client watched the send fail, and {@link #UNOBSERVED}, + * which means nobody knows. Collapsing the second into the first is the mistake this axis exists to + * prevent: it turns "we do not know whether the server got the request" into "the server did not + * get the request", and that is the reasoning that replays a payment. + */ +public enum GrpcTransportEvidence { + /** The client observed the send fail before any byte reached the wire. */ + NOT_SENT, + /** Bytes left the client. Nothing is known about what the server did with them. */ + SENT_UNCONFIRMED, + /** Response headers arrived. This says the server started; it does not say it committed. */ + RESPONSE_HEADERS_SEEN, + /** At least one response message arrived. */ + RESPONSE_MESSAGE_SEEN, + /** Trailers arrived, so the call completed at the transport level. */ + TRAILERS_SEEN, + /** The call ended without the client observing enough to place it above. */ + UNOBSERVED; + + /** + * Whether the client saw the server begin producing a response. + * + *

Deliberately not named anything with "success" or "committed" in it. Response headers are a + * transport fact, and the only business meaning they carry is the one the business axis records + * separately. + */ + public boolean serverResponded() { + return this == RESPONSE_HEADERS_SEEN || this == RESPONSE_MESSAGE_SEEN || this == TRAILERS_SEEN; + } + + /** Whether this observation, alone, rules out the server having started the work. */ + public boolean provesNotStarted() { + return this == NOT_SENT; + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationIdentity.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationIdentity.java new file mode 100644 index 00000000..5239c717 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationIdentity.java @@ -0,0 +1,39 @@ +package dev.caskeleton.grpc.ledger; + +import dev.caskeleton.grpc.core.GrpcMethodName; + +/** + * What makes two requests the same operation. + * + *

All three parts are load-bearing. Without the caller fingerprint, one tenant's idempotency key + * suppresses another tenant's write. Without the method, the same key reused across two operations + * makes the second one a replay of the first. Without the key, there is no operation identity at + * all. + * + *

The key is stored as a hash, never in the clear: an idempotency key is chosen by the caller + * and is frequently an order id, an invoice number or something else that identifies a person. + */ +public record GrpcOperationIdentity( + String callerFingerprint, GrpcMethodName method, String idempotencyKeyHash) { + + /** Requires all three parts, with the key already hashed. */ + public GrpcOperationIdentity { + if (callerFingerprint == null || callerFingerprint.isBlank()) { + throw new IllegalArgumentException( + "an operation identity needs a caller fingerprint; without one, a key from one tenant " + + "suppresses another tenant's write"); + } + if (method == null) { + throw new IllegalArgumentException("an operation identity names its method"); + } + if (idempotencyKeyHash == null || !idempotencyKeyHash.startsWith("sha256:")) { + throw new IllegalArgumentException( + "the idempotency key is stored hashed; got '" + idempotencyKeyHash + "'"); + } + } + + /** The stable storage key: the three parts joined, safe to index and safe to log. */ + public String storageKey() { + return callerFingerprint + "|" + method.canonical() + "|" + idempotencyKeyHash; + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationLedger.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationLedger.java new file mode 100644 index 00000000..fb2a3157 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationLedger.java @@ -0,0 +1,50 @@ +package dev.caskeleton.grpc.ledger; + +import java.time.Instant; +import java.util.Optional; + +/** + * The durable claim store behind idempotent mutations. + * + *

A port in the API leaf rather than an interface next to its JPA implementation, so the policy + * layer can require durable idempotency without depending on a database. The contract that matters + * is on {@link #claim}: it must be atomic against a unique constraint, because the two concurrent + * attempts this exists to separate arrive at the same microsecond. + */ +public interface GrpcOperationLedger { + + /** + * Claims {@code identity}, or returns the claim that already exists. + * + *

Must be a single atomic insert-or-read against a unique constraint. A read-then-insert + * implementation admits both concurrent attempts, which is the exact failure the ledger exists to + * prevent, and it does so only under the load where it matters. + * + * @return the existing record when one was already claimed, empty when this call created it + */ + Optional claim( + GrpcOperationIdentity identity, String requestFingerprint, Instant now); + + /** The record for {@code identity}, if there is one. */ + Optional find(GrpcOperationIdentity identity); + + /** + * Marks a claim committed with a replayable outcome. + * + *

Called inside the business transaction wherever the datastore allows it. When the ledger and + * the mutation commit separately, there is a window in which the mutation is durable and the + * claim is not, and a retry in that window runs the mutation twice. + */ + void markCommitted(GrpcOperationIdentity identity, String outcomeReference, Instant now); + + /** Marks a claim terminally failed. */ + void markFailed(GrpcOperationIdentity identity, Instant now); + + /** + * Releases a claim that will never complete, so the caller may try again. + * + *

Only for a claim whose owner is known to be gone. Releasing one whose owner is merely slow + * re-admits the duplicate. + */ + void release(GrpcOperationIdentity identity); +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationLedgerRecord.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationLedgerRecord.java new file mode 100644 index 00000000..83606a27 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationLedgerRecord.java @@ -0,0 +1,59 @@ +package dev.caskeleton.grpc.ledger; + +import java.time.Instant; +import java.util.Optional; + +/** + * One durable operation claim, as the policy layer sees it. + * + *

The request fingerprint is what turns the ledger from a deduplicator into a correctness check. + * A caller that reuses a key for a different request has made a mistake, and returning the first + * request's answer would hide it; comparing fingerprints turns that into a {@code + * FAILED_PRECONDITION} the caller can act on. + */ +public record GrpcOperationLedgerRecord( + GrpcOperationIdentity identity, + GrpcOperationLedgerState state, + String requestFingerprint, + Optional outcomeReference, + Instant claimedAt, + Optional completedAt) { + + /** Requires an identity, a state, a fingerprint and a claim moment. */ + public GrpcOperationLedgerRecord { + if (identity == null || state == null) { + throw new IllegalArgumentException("a ledger record needs an identity and a state"); + } + if (requestFingerprint == null || !requestFingerprint.startsWith("sha256:")) { + throw new IllegalArgumentException("a ledger record stores a hashed request fingerprint"); + } + if (claimedAt == null || outcomeReference == null || completedAt == null) { + throw new IllegalArgumentException("a ledger record needs its timestamps and Optionals"); + } + if (state.terminal() && completedAt.isEmpty()) { + throw new IllegalArgumentException("a terminal ledger record records when it finished"); + } + if (state == GrpcOperationLedgerState.COMMITTED && outcomeReference.isEmpty()) { + throw new IllegalArgumentException( + "a COMMITTED record without an outcome reference cannot be replayed, which is the only " + + "reason to record a commit"); + } + } + + /** A fresh claim. */ + public static GrpcOperationLedgerRecord claim( + GrpcOperationIdentity identity, String requestFingerprint, Instant claimedAt) { + return new GrpcOperationLedgerRecord( + identity, + GrpcOperationLedgerState.IN_PROGRESS, + requestFingerprint, + Optional.empty(), + claimedAt, + Optional.empty()); + } + + /** Whether {@code candidateFingerprint} is the same request this claim was made for. */ + public boolean sameRequestAs(String candidateFingerprint) { + return requestFingerprint.equals(candidateFingerprint); + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationLedgerState.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationLedgerState.java new file mode 100644 index 00000000..f584aeb1 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationLedgerState.java @@ -0,0 +1,28 @@ +package dev.caskeleton.grpc.ledger; + +/** + * Where a durable operation claim is in its life. + * + *

Three states, and the first is the one that carries the weight: {@link #IN_PROGRESS} is a + * durable record that somebody started this exact operation and has not finished. Without it, two + * concurrent attempts with the same idempotency key both find nothing and both run. + */ +public enum GrpcOperationLedgerState { + /** Claimed and running. A duplicate arriving now must wait, poll or be refused. */ + IN_PROGRESS(false), + /** Committed, with a replayable outcome. */ + COMMITTED(true), + /** Failed in a way that will not succeed on repetition. */ + FAILED_TERMINAL(true); + + private final boolean terminal; + + GrpcOperationLedgerState(boolean terminal) { + this.terminal = terminal; + } + + /** Whether the operation has finished, successfully or not. */ + public boolean terminal() { + return terminal; + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/policy/GrpcMethodPolicy.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/policy/GrpcMethodPolicy.java new file mode 100644 index 00000000..8e137f6c --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/policy/GrpcMethodPolicy.java @@ -0,0 +1,100 @@ +package dev.caskeleton.grpc.policy; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; + +/** + * Everything the platform decides per method, in one immutable value. + * + *

The contradictions are rejected here rather than at the point of use. A method declared + * non-idempotent with an explicit retry policy is not a runtime edge case to handle; it is a + * configuration that should never have been constructible, and the compact constructor is where + * that is true. + */ +public record GrpcMethodPolicy( + GrpcMethodName method, + RpcType rpcType, + RpcIdempotencyProfile idempotency, + GrpcDeadlineProfile deadline, + WaitForReadyPolicy waitForReady, + boolean explicitRetryEnabled, + int maxInboundMessageBytes, + int maxOutboundMessageBytes) { + + /** Rejects every combination the Stable plan forbids. */ + public GrpcMethodPolicy { + if (method == null || rpcType == null || idempotency == null || waitForReady == null) { + throw new IllegalArgumentException("method policy fields must all be present"); + } + if (deadline == null) { + throw new IllegalArgumentException( + "method '" + + method.canonical() + + "' has no deadline profile; every Stable method needs one"); + } + if (maxInboundMessageBytes <= 0 || maxOutboundMessageBytes <= 0) { + throw new IllegalArgumentException("message size limits must be positive"); + } + // Streaming is checked first because both rules fire for a streaming method and only one of + // them explains why: "it is STREAMING" restates the declaration, while "a retry would redeliver + // a prefix the client already saw" is the reason the declaration exists. + if (rpcType.streaming() && explicitRetryEnabled) { + throw new IllegalArgumentException( + "method '" + + method.canonical() + + "' streams; a whole-call retry would redeliver a prefix the client already saw"); + } + if (explicitRetryEnabled && !idempotency.explicitRetryAllowed()) { + throw new IllegalArgumentException( + "method '" + + method.canonical() + + "' is " + + idempotency + + " and must not carry an explicit retry policy"); + } + if (idempotency == RpcIdempotencyProfile.STREAMING && !rpcType.streaming()) { + throw new IllegalArgumentException( + "method '" + method.canonical() + "' is unary but declares the STREAMING profile"); + } + if (waitForReady.queues() && deadline.total().isZero()) { + throw new IllegalArgumentException( + "method '" + + method.canonical() + + "' enables wait-for-ready without a deadline to bound it"); + } + } + + /** A read-only unary method with the platform's default reserves. */ + public static GrpcMethodPolicy readOnlyUnary( + GrpcMethodName method, GrpcDeadlineProfile deadline) { + return new GrpcMethodPolicy( + method, + RpcType.UNARY, + RpcIdempotencyProfile.READ_ONLY, + deadline, + WaitForReadyPolicy.DISABLED, + true, + 4 * 1024 * 1024, + 4 * 1024 * 1024); + } + + /** A state-changing unary method that refuses explicit retry. */ + public static GrpcMethodPolicy nonIdempotentUnary( + GrpcMethodName method, GrpcDeadlineProfile deadline) { + return new GrpcMethodPolicy( + method, + RpcType.UNARY, + RpcIdempotencyProfile.NON_IDEMPOTENT, + deadline, + WaitForReadyPolicy.DISABLED, + false, + 4 * 1024 * 1024, + 4 * 1024 * 1024); + } + + /** Whether this method may be hedged. */ + public boolean hedgingAllowed() { + return rpcType == RpcType.UNARY && idempotency.hedgingAllowed(); + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/policy/GrpcMethodPolicyCatalog.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/policy/GrpcMethodPolicyCatalog.java new file mode 100644 index 00000000..f6e694ae --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/policy/GrpcMethodPolicyCatalog.java @@ -0,0 +1,124 @@ +package dev.caskeleton.grpc.policy; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * The immutable method-policy lookup, built once and then only read. + * + *

Its most useful property is the one that fails a build: when a descriptor method set is + * declared, registering a policy for a method the schema does not have is an error. That catches + * the rename — the method becomes {@code CreateDocumentV2}, the policy still names {@code + * CreateDocument}, and every call to the new method silently runs with default deadline, default + * retry and no idempotency requirement. + */ +public final class GrpcMethodPolicyCatalog { + + private final Map policies; + + private GrpcMethodPolicyCatalog(Map policies) { + this.policies = Map.copyOf(policies); + } + + /** A new builder. */ + public static Builder builder() { + return new Builder(); + } + + /** The policy for {@code method}, if one is registered. */ + public Optional find(GrpcMethodName method) { + return Optional.ofNullable(policies.get(method)); + } + + /** + * The policy for {@code method}. + * + * @throws IllegalStateException when none is registered — a call with no policy has no deadline + * and no idempotency profile, and serving it would mean inventing both + */ + public GrpcMethodPolicy require(GrpcMethodName method) { + GrpcMethodPolicy policy = policies.get(method); + if (policy == null) { + throw new IllegalStateException( + "no method policy registered for '" + + method.canonical() + + "'; a Stable method without a policy has no deadline and no idempotency profile"); + } + return policy; + } + + /** Every registered method. */ + public Set methods() { + return policies.keySet(); + } + + /** How many methods carry a policy. */ + public int size() { + return policies.size(); + } + + /** Accumulates policies and validates the set as a whole at {@link #build()}. */ + public static final class Builder { + + private final Map policies = new LinkedHashMap<>(); + private final Set descriptorMethods = new LinkedHashSet<>(); + private boolean descriptorDeclared; + + private Builder() {} + + /** + * Declares the methods the compiled schema actually contains. + * + *

Optional, and the catalog is materially weaker without it: without a descriptor there is + * nothing to compare a policy's method name against. + */ + public Builder withDescriptorMethods(Set methods) { + if (methods == null || methods.isEmpty()) { + throw new IllegalArgumentException("a descriptor method set must not be empty"); + } + descriptorMethods.addAll(methods); + descriptorDeclared = true; + return this; + } + + /** + * Registers one method policy. + * + * @throws IllegalArgumentException on a duplicate registration — two policies for one method + * means the effective policy depends on registration order + */ + public Builder register(GrpcMethodPolicy policy) { + if (policy == null) { + throw new IllegalArgumentException("policy must not be null"); + } + GrpcMethodPolicy previous = policies.putIfAbsent(policy.method(), policy); + if (previous != null) { + throw new IllegalArgumentException( + "duplicate method policy for '" + policy.method().canonical() + "'"); + } + return this; + } + + /** + * Builds the catalog. + * + * @throws IllegalStateException when a registered method is absent from the declared descriptor + */ + public GrpcMethodPolicyCatalog build() { + if (descriptorDeclared) { + Set unknown = new LinkedHashSet<>(policies.keySet()); + unknown.removeAll(descriptorMethods); + if (!unknown.isEmpty()) { + throw new IllegalStateException( + "method policies reference methods the schema does not declare: " + + unknown.stream().map(GrpcMethodName::canonical).sorted().toList()); + } + } + return new GrpcMethodPolicyCatalog(policies); + } + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/policy/RpcIdempotencyProfile.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/policy/RpcIdempotencyProfile.java new file mode 100644 index 00000000..362e2b30 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/policy/RpcIdempotencyProfile.java @@ -0,0 +1,49 @@ +package dev.caskeleton.grpc.policy; + +/** + * How safe a method is to run more than once — the property every retry decision starts from. + * + *

Idempotency is a property of the operation, not of the failure, and keeping it on the method + * policy rather than deriving it from a status is what stops "UNAVAILABLE means retry" from being + * applied to a transfer. + */ +public enum RpcIdempotencyProfile { + /** Reads nothing changes. Retryable and the only profile eligible for hedging. */ + READ_ONLY(true, true, false), + /** Repeating the write reaches the same end state — a set-to-value, a delete-by-id. */ + NATURALLY_IDEMPOTENT(true, false, false), + /** Safe to repeat only with a caller-supplied key and a durable ledger behind it. */ + IDEMPOTENCY_KEY_REQUIRED(true, false, true), + /** Safe to repeat only when a precondition — a version, an ETag — is carried and checked. */ + CONDITIONALLY_IDEMPOTENT(true, false, false), + /** Repeating it does it twice. No explicit retry, no hedging, ever. */ + NON_IDEMPOTENT(false, false, false), + /** A stream. Whole-call retry is meaningless once a prefix has been delivered. */ + STREAMING(false, false, false); + + private final boolean explicitRetryAllowed; + private final boolean hedgingAllowed; + private final boolean idempotencyKeyRequired; + + RpcIdempotencyProfile( + boolean explicitRetryAllowed, boolean hedgingAllowed, boolean idempotencyKeyRequired) { + this.explicitRetryAllowed = explicitRetryAllowed; + this.hedgingAllowed = hedgingAllowed; + this.idempotencyKeyRequired = idempotencyKeyRequired; + } + + /** Whether an explicit retry policy may be attached to a method with this profile. */ + public boolean explicitRetryAllowed() { + return explicitRetryAllowed; + } + + /** Whether duplicate in-flight attempts may be issued. Only reads qualify. */ + public boolean hedgingAllowed() { + return hedgingAllowed; + } + + /** Whether the caller must supply an idempotency key for the call to be accepted. */ + public boolean idempotencyKeyRequired() { + return idempotencyKeyRequired; + } +} diff --git a/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/policy/WaitForReadyPolicy.java b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/policy/WaitForReadyPolicy.java new file mode 100644 index 00000000..2eb390c1 --- /dev/null +++ b/src/grpc/grpc-core-api/src/main/java/dev/caskeleton/grpc/policy/WaitForReadyPolicy.java @@ -0,0 +1,22 @@ +package dev.caskeleton.grpc.policy; + +/** + * Whether a call queues while the channel is disconnected, instead of failing fast. + * + *

Default off. Wait-for-ready turns a fast {@code UNAVAILABLE} into a wait that ends at the + * deadline, which is the right trade for a batch worker and the wrong one for a request a person is + * watching — the user gets the whole deadline as latency and then an error anyway. + */ +public enum WaitForReadyPolicy { + /** Fail fast when the channel is not ready. The default for every method. */ + DISABLED, + /** A worker or batch path may queue inside its deadline and queue budget. */ + WORKER_OPT_IN, + /** A user-synchronous path that has been explicitly reviewed and approved to queue. */ + APPROVED_SYNCHRONOUS; + + /** Whether calls under this policy queue rather than fail fast. */ + public boolean queues() { + return this != DISABLED; + } +} diff --git a/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/context/GrpcMetadataBudgetTest.java b/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/context/GrpcMetadataBudgetTest.java new file mode 100644 index 00000000..8164f2d2 --- /dev/null +++ b/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/context/GrpcMetadataBudgetTest.java @@ -0,0 +1,174 @@ +package dev.caskeleton.grpc.context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.deadline.GrpcCancellationToken; +import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcMetadataBudgetTest { + + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + private static final GrpcMetadataKey CORRELATION = GrpcMetadataKey.ascii("x-correlation-id"); + private static final GrpcMetadataKey IDEMPOTENCY = GrpcMetadataKey.ascii("x-idempotency-key"); + + private static GrpcDeadlineBudget budget() { + return GrpcDeadlineBudget.forEntryPoint( + Duration.ofSeconds(1), GrpcDeadlineProfile.of(Duration.ofSeconds(2))); + } + + @Test + @DisplayName("a metadata key may not borrow the reserved grpc- prefix") + void reservedPrefixIsRefused() { + assertThatThrownBy(() -> GrpcMetadataKey.ascii("grpc-tenant")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reserved"); + } + + @Test + @DisplayName("binary keys end in -bin and ASCII keys do not") + void binarySuffixMatchesTheKind() { + assertThat(GrpcMetadataKey.binary("trace-state-bin").kind()) + .isEqualTo(GrpcMetadataKey.Kind.BINARY); + assertThatThrownBy(() -> GrpcMetadataKey.binary("trace-state")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> GrpcMetadataKey.ascii("trace-state-bin")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("uppercase and control-character keys are refused") + void nonCanonicalKeysAreRefused() { + assertThatThrownBy(() -> GrpcMetadataKey.ascii("X-Correlation-Id")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> GrpcMetadataKey.ascii("")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the user-defined allowance is separate from, and never exceeds, the hard total") + void userDefinedAllowanceIsSeparateFromTheHardTotal() { + GrpcMetadataBudget standard = GrpcMetadataBudget.standard(); + + assertThat(standard.maxUserDefinedBytes()).isLessThan(standard.maxTotalBytes()); + assertThatThrownBy(() -> new GrpcMetadataBudget(1024, 2048, 8)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot exceed the hard total"); + } + + @Test + @DisplayName("the budget is checked before the call starts, naming the bound that was exceeded") + void budgetIsCheckedBeforeTheCallStarts() { + GrpcMetadataBudget tight = new GrpcMetadataBudget(200, 40, 4); + Map oversized = new LinkedHashMap<>(); + oversized.put(CORRELATION, "x".repeat(64)); + + assertThatThrownBy(() -> tight.check(oversized)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("user-defined metadata"); + + Map tooManyEntries = new LinkedHashMap<>(); + tooManyEntries.put(CORRELATION, "a"); + tooManyEntries.put(IDEMPOTENCY, "b"); + tooManyEntries.put(GrpcMetadataKey.ascii("x-a"), "c"); + tooManyEntries.put(GrpcMetadataKey.ascii("x-b"), "d"); + tooManyEntries.put(GrpcMetadataKey.ascii("x-c"), "e"); + assertThatThrownBy(() -> tight.check(tooManyEntries)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("entries"); + } + + @Test + @DisplayName("a request context keeps only allowlisted metadata and refuses the rest loudly") + void requestContextRefusesMetadataOutsideTheAllowlist() { + Map inbound = + Map.of(CORRELATION, "corr-1", GrpcMetadataKey.ascii("x-tenant-claim"), "other-tenant"); + + assertThatThrownBy( + () -> + GrpcRequestContext.create( + GET, + RpcType.UNARY, + GrpcClientIdentity.anonymous(), + budget(), + new GrpcCancellationToken(), + inbound, + Set.of(CORRELATION), + GrpcMetadataBudget.standard(), + null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("x-tenant-claim") + .hasMessageContaining("allowlist"); + } + + @Test + @DisplayName("a request context exposes no raw metadata map and is immutable once built") + void requestContextIsImmutable() { + Map inbound = new LinkedHashMap<>(); + inbound.put(CORRELATION, "corr-1"); + + GrpcRequestContext context = + GrpcRequestContext.create( + GET, + RpcType.UNARY, + GrpcClientIdentity.fromVerifiedAuthentication("actor-1", "tenant-1", "jwt-issuer"), + budget(), + new GrpcCancellationToken(), + inbound, + Set.of(CORRELATION), + GrpcMetadataBudget.standard(), + "trace-1"); + + inbound.put(IDEMPOTENCY, "leaked-after-the-fact"); + + assertThat(context.metadata()).containsOnlyKeys(CORRELATION); + assertThat(context.metadataValue(CORRELATION)).contains("corr-1"); + assertThat(context.metadataValue(IDEMPOTENCY)).isEmpty(); + assertThat(context.traceId()).contains("trace-1"); + assertThat(context.live()).isTrue(); + } + + @Test + @DisplayName("identity names the verifier that established it") + void identityNamesItsVerifier() { + GrpcClientIdentity identity = + GrpcClientIdentity.fromVerifiedAuthentication("actor-1", "tenant-1", "mtls-peer-subject"); + + assertThat(identity.authenticationSource()).isEqualTo("mtls-peer-subject"); + assertThatThrownBy( + () -> GrpcClientIdentity.fromVerifiedAuthentication("actor-1", "tenant-1", " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("authentication source"); + } + + @Test + @DisplayName("a cancelled or expired context is not live") + void cancelledContextIsNotLive() { + GrpcCancellationToken token = new GrpcCancellationToken(); + GrpcRequestContext context = + GrpcRequestContext.create( + GET, + RpcType.UNARY, + GrpcClientIdentity.anonymous(), + budget(), + token, + Map.of(), + Set.of(), + GrpcMetadataBudget.standard(), + null); + + token.cancel("client-cancelled", java.time.Instant.parse("2026-08-30T10:00:00Z")); + + assertThat(context.live()).isFalse(); + } +} diff --git a/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/core/GrpcCoreIdentifiersTest.java b/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/core/GrpcCoreIdentifiersTest.java new file mode 100644 index 00000000..08ce0a83 --- /dev/null +++ b/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/core/GrpcCoreIdentifiersTest.java @@ -0,0 +1,83 @@ +package dev.caskeleton.grpc.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class GrpcCoreIdentifiersTest { + + @Test + @DisplayName("a full method name round-trips through its canonical form") + void methodNameRoundTripsThroughCanonicalForm() { + GrpcMethodName parsed = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + + assertThat(parsed.canonical()).isEqualTo("hyeonworks.document.v1.DocumentService/GetDocument"); + assertThat(parsed.method()).isEqualTo("GetDocument"); + assertThat(parsed.service().simpleName()).isEqualTo("DocumentService"); + assertThat(parsed.service().protoPackage()).isEqualTo("hyeonworks.document.v1"); + assertThat(GrpcMethodName.parse(parsed.canonical())).isEqualTo(parsed); + } + + @ParameterizedTest + @ValueSource( + strings = { + "DocumentService/Get", + "hyeonworks.document.v1.DocumentService", + "hyeonworks.document.v1.DocumentService/Get/Extra", + "hyeonworks.document.v1.DocumentService/getDocument" + }) + @DisplayName("a name that is not exactly package.Service/Method is refused") + void nonCanonicalMethodNamesAreRefused(String candidate) { + assertThatThrownBy(() -> GrpcMethodName.parse(candidate)) + .isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"", "svc name", "Has Space", "tab\tseparated"}) + @DisplayName("blank, whitespace and control-character identifiers are refused") + void blankAndControlCharacterIdentifiersAreRefused(String candidate) { + assertThatThrownBy(() -> new GrpcServiceName(candidate)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new GrpcChannelProfileName(candidate)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a channel profile name is lower-kebab-case") + void channelProfileNameIsLowerKebabCase() { + assertThat(new GrpcChannelProfileName("document-service-read").value()) + .isEqualTo("document-service-read"); + assertThatThrownBy(() -> new GrpcChannelProfileName("DocumentService")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("RpcType has exactly the four shapes, and only the two Stable ones are Stable") + void rpcTypeHasExactlyFourShapesAndTwoAreStable() { + assertThat(RpcType.values()) + .containsExactly( + RpcType.UNARY, + RpcType.SERVER_STREAMING, + RpcType.CLIENT_STREAMING, + RpcType.BIDI_STREAMING); + assertThat(RpcType.UNARY.stable()).isTrue(); + assertThat(RpcType.SERVER_STREAMING.stable()).isTrue(); + assertThat(RpcType.CLIENT_STREAMING.stable()).isFalse(); + assertThat(RpcType.BIDI_STREAMING.stable()).isFalse(); + assertThat(RpcType.UNARY.streaming()).isFalse(); + assertThat(RpcType.SERVER_STREAMING.streaming()).isTrue(); + } + + @Test + @DisplayName("no status code claims, on its own, that the server never started") + void statusCodeNeverProvesTheServerDidNotStart() { + assertThat(GrpcStatusCode.values()).noneMatch(GrpcStatusCode::provesNotStarted); + assertThat(GrpcStatusCode.DEADLINE_EXCEEDED.value()).isEqualTo(4); + assertThat(GrpcStatusCode.UNAVAILABLE.value()).isEqualTo(14); + } +} diff --git a/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/core/GrpcStableModuleCatalogTest.java b/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/core/GrpcStableModuleCatalogTest.java new file mode 100644 index 00000000..4135d34e --- /dev/null +++ b/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/core/GrpcStableModuleCatalogTest.java @@ -0,0 +1,60 @@ +package dev.caskeleton.grpc.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcStableModuleCatalogTest { + + @Test + @DisplayName("the Stable module set is exact and excludes every advanced module") + void stableModuleSetIsExactAndAdvancedIsExcluded() { + GrpcStableModuleCatalog catalog = GrpcStableModuleCatalog.standard(); + + assertThat(catalog.modules()) + .contains("grpc-core-api", "grpc-client", "grpc-server", "grpc-policy") + .doesNotContainAnyElementsOf(GrpcStableModuleCatalog.advancedModules()); + assertThat(GrpcStableBuildInvariant.advancedDependencyAllowed()).isFalse(); + } + + @Test + @DisplayName("the Stable and Advanced sets are disjoint") + void stableAndAdvancedAreDisjoint() { + GrpcStableModuleCatalog catalog = GrpcStableModuleCatalog.standard(); + + assertThat(GrpcStableModuleCatalog.advancedModules()).isNotEmpty().noneMatch(catalog::isStable); + } + + @Test + @DisplayName("a Stable module reaching an advanced module fails, naming every leak") + void stableModuleMayNotDependOnAdvanced() { + Set dependencies = + Set.of("grpc-core-api", "grpc-advanced-streaming", "grpc-advanced-compat"); + + assertThatThrownBy( + () -> + GrpcStableBuildInvariant.requireNoAdvancedDependency( + "grpc-spring-boot-starter", dependencies)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("grpc-spring-boot-starter") + .hasMessageContaining("grpc-advanced-streaming") + .hasMessageContaining("grpc-advanced-compat"); + } + + @Test + @DisplayName("a Stable module with only Stable dependencies passes") + void stableOnlyDependenciesPass() { + GrpcStableBuildInvariant.requireNoAdvancedDependency( + "grpc-server", Set.of("grpc-core-api", "grpc-policy")); + } + + @Test + @DisplayName("an advanced module is not judged by the Stable invariant") + void advancedModuleIsNotGovernedByTheStableRule() { + GrpcStableBuildInvariant.requireNoAdvancedDependency( + "grpc-advanced-compat", Set.of("grpc-advanced-bootstrap")); + } +} diff --git a/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/deadline/GrpcDeadlineBudgetTest.java b/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/deadline/GrpcDeadlineBudgetTest.java new file mode 100644 index 00000000..ee87c13b --- /dev/null +++ b/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/deadline/GrpcDeadlineBudgetTest.java @@ -0,0 +1,124 @@ +package dev.caskeleton.grpc.deadline; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcDeadlineBudgetTest { + + private static final GrpcDeadlineProfile TWO_SECONDS = + GrpcDeadlineProfile.of(Duration.ofSeconds(2)); + + @Test + @DisplayName("a Stable unary call without a propagated deadline is refused") + void unaryEntryPointRequiresAPositiveDeadline() { + assertThatThrownBy(() -> GrpcDeadlineBudget.forEntryPoint(null, TWO_SECONDS)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("positive deadline"); + assertThatThrownBy(() -> GrpcDeadlineBudget.forEntryPoint(Duration.ZERO, TWO_SECONDS)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the budget takes the shorter of the parent remaining and the method default") + void budgetTakesTheShorterOfParentAndMethodDefault() { + GrpcDeadlineBudget fromShortParent = + GrpcDeadlineBudget.forEntryPoint(Duration.ofMillis(500), TWO_SECONDS); + GrpcDeadlineBudget fromLongParent = + GrpcDeadlineBudget.forEntryPoint(Duration.ofSeconds(30), TWO_SECONDS); + + assertThat(fromShortParent.remaining()) + .isEqualTo(Duration.ofMillis(500).minus(TWO_SECONDS.safetyMargin())); + assertThat(fromLongParent.remaining()) + .isEqualTo(TWO_SECONDS.total().minus(TWO_SECONDS.safetyMargin())); + } + + @Test + @DisplayName("the safety reserve is subtracted so serialization and trailers have room") + void safetyReserveIsSubtracted() { + GrpcDeadlineProfile profile = + new GrpcDeadlineProfile( + Duration.ofSeconds(10), Duration.ofSeconds(1), Duration.ofMillis(100)); + + GrpcDeadlineBudget budget = GrpcDeadlineBudget.forEntryPoint(Duration.ofSeconds(10), profile); + + assertThat(budget.remaining()).isEqualTo(Duration.ofSeconds(9)); + assertThat(profile.usable()).isEqualTo(Duration.ofSeconds(9)); + } + + @Test + @DisplayName("a downstream call never gets more time than its caller has left") + void downstreamNeverExceedsTheCaller() { + GrpcDeadlineBudget caller = + GrpcDeadlineBudget.forEntryPoint(Duration.ofMillis(400), TWO_SECONDS); + GrpcDeadlineProfile generousDownstream = + new GrpcDeadlineProfile(Duration.ofSeconds(30), Duration.ZERO, Duration.ofMillis(10)); + + GrpcDeadlineBudget downstream = caller.deriveDownstream(generousDownstream); + + assertThat(downstream.remaining()).isLessThanOrEqualTo(caller.remaining()); + } + + @Test + @DisplayName("a dependency call is not started with less than the minimum attempt budget") + void dependencyCallIsRefusedBelowTheMinimumAttemptBudget() { + GrpcDeadlineProfile profile = + new GrpcDeadlineProfile( + Duration.ofSeconds(1), Duration.ofMillis(100), Duration.ofMillis(250)); + + assertThat(new GrpcDeadlineBudget(Duration.ofMillis(300), profile).canStartDependencyCall()) + .isTrue(); + assertThat(new GrpcDeadlineBudget(Duration.ofMillis(100), profile).canStartDependencyCall()) + .isFalse(); + assertThat(new GrpcDeadlineBudget(Duration.ZERO, profile).canStartDependencyCall()).isFalse(); + } + + @Test + @DisplayName("a profile whose reserve leaves no usable time is refused") + void profileWithNoUsableTimeIsRefused() { + assertThatThrownBy( + () -> + new GrpcDeadlineProfile( + Duration.ofSeconds(1), Duration.ofSeconds(1), Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("leaves no time"); + assertThatThrownBy(() -> GrpcDeadlineProfile.of(Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("cancellation is idempotent and keeps the first reason") + void cancellationIsIdempotentAndKeepsTheFirstReason() { + GrpcCancellationToken token = new GrpcCancellationToken(); + Instant first = Instant.parse("2026-08-30T10:00:00Z"); + + assertThat(token.cancel("client-cancelled", first)).isTrue(); + assertThat(token.cancel("server-drain", first.plusSeconds(1))).isFalse(); + + assertThat(token.cancelled()).isTrue(); + assertThat(token.cancellation()) + .hasValueSatisfying( + c -> { + assertThat(c.reason()).isEqualTo("client-cancelled"); + assertThat(c.at()).isEqualTo(first); + }); + } + + @Test + @DisplayName("a cancelled call refuses to start a new side effect") + void cancelledCallRefusesANewSideEffect() { + GrpcCancellationToken token = new GrpcCancellationToken(); + token.requireNotCancelled("charge-card"); + + token.cancel("deadline-exceeded", Instant.parse("2026-08-30T10:00:00Z")); + + assertThatThrownBy(() -> token.requireNotCancelled("charge-card")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("charge-card") + .hasMessageContaining("deadline-exceeded"); + } +} diff --git a/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/error/GrpcFailureContextTest.java b/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/error/GrpcFailureContextTest.java new file mode 100644 index 00000000..2cdbd5c1 --- /dev/null +++ b/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/error/GrpcFailureContextTest.java @@ -0,0 +1,182 @@ +package dev.caskeleton.grpc.error; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.GrpcStatusCode; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.evidence.GrpcBusinessEvidence; +import dev.caskeleton.grpc.evidence.GrpcExecutionEvidence; +import dev.caskeleton.grpc.evidence.GrpcStreamEvidence; +import dev.caskeleton.grpc.evidence.GrpcTransportEvidence; +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcFailureContextTest { + + private static final GrpcMethodName CREATE = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument"); + private static final GrpcMethodName WATCH = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/WatchDocuments"); + + private static GrpcExecutionEvidence sentButUnanswered() { + return new GrpcExecutionEvidence( + CREATE, + RpcType.UNARY, + GrpcTransportEvidence.SENT_UNCONFIRMED, + GrpcBusinessEvidence.ATTEMPTED, + GrpcStreamEvidence.none()); + } + + @Test + @DisplayName("a deadline on a mutation is COMPLETION_UNKNOWN, not a rejection") + void deadlineOnAMutationIsCompletionUnknown() { + GrpcCompletionOutcome outcome = + GrpcCompletionOutcome.forMutation(GrpcStatusCode.DEADLINE_EXCEEDED, sentButUnanswered()); + + assertThat(outcome).isEqualTo(GrpcCompletionOutcome.COMPLETION_UNKNOWN); + assertThat(outcome.requiresReconciliation()).isTrue(); + } + + @Test + @DisplayName("UNAVAILABLE alone does not make a mutation safe to re-issue") + void unavailableAloneDoesNotMakeAMutationRepeatable() { + assertThat(GrpcCompletionOutcome.forMutation(GrpcStatusCode.UNAVAILABLE, sentButUnanswered())) + .isEqualTo(GrpcCompletionOutcome.COMPLETION_UNKNOWN); + } + + @Test + @DisplayName("a send the client watched fail is a rejection, because nothing ran") + void observedFailedSendIsARejection() { + GrpcExecutionEvidence notSent = GrpcExecutionEvidence.notStarted(CREATE, RpcType.UNARY); + + assertThat(GrpcCompletionOutcome.forMutation(GrpcStatusCode.UNAVAILABLE, notSent)) + .isEqualTo(GrpcCompletionOutcome.REJECTED); + } + + @Test + @DisplayName("a confirmed commit outranks whatever status the transport reported") + void confirmedCommitOutranksTheStatus() { + GrpcExecutionEvidence committed = sentButUnanswered().withCommitConfirmed(); + + assertThat(GrpcCompletionOutcome.forMutation(GrpcStatusCode.DEADLINE_EXCEEDED, committed)) + .isEqualTo(GrpcCompletionOutcome.COMPLETED); + } + + @Test + @DisplayName("a delivered stream prefix is PARTIAL_STREAM") + void deliveredStreamPrefixIsPartialStream() { + GrpcExecutionEvidence partial = + new GrpcExecutionEvidence( + WATCH, + RpcType.SERVER_STREAMING, + GrpcTransportEvidence.RESPONSE_MESSAGE_SEEN, + GrpcBusinessEvidence.NONE, + new GrpcStreamEvidence.Partial(9L)); + + assertThat(GrpcCompletionOutcome.forMutation(GrpcStatusCode.UNAVAILABLE, partial)) + .isEqualTo(GrpcCompletionOutcome.PARTIAL_STREAM); + } + + @Test + @DisplayName("the redacted summary carries identity and timing, never payload or credentials") + void redactedSummaryCarriesNoSecrets() { + GrpcFailureContext context = + new GrpcFailureContext( + CREATE, + GrpcStatusCode.DEADLINE_EXCEEDED, + GrpcFailureCategory.DEADLINE, + sentButUnanswered(), + GrpcCompletionOutcome.COMPLETION_UNKNOWN, + GrpcFailureContext.RetryDisposition.RESOLVE_FIRST, + 2, + Duration.ofMillis(1500), + Optional.of("trace-abc")); + + String summary = context.redactedSummary(); + + assertThat(summary) + .contains("hyeonworks.document.v1.DocumentService/CreateDocument") + .contains("DEADLINE_EXCEEDED") + .contains("COMPLETION_UNKNOWN") + .contains("RESOLVE_FIRST") + .contains("elapsedMs=1500") + .contains("trace-abc"); + } + + @Test + @DisplayName("a failure context whose method disagrees with its evidence is refused") + void methodMustAgreeWithEvidence() { + assertThatThrownBy( + () -> + new GrpcFailureContext( + WATCH, + GrpcStatusCode.INTERNAL, + GrpcFailureCategory.INTERNAL, + sentButUnanswered(), + GrpcCompletionOutcome.COMPLETION_UNKNOWN, + GrpcFailureContext.RetryDisposition.NOT_RETRYABLE, + 1, + Duration.ZERO, + Optional.empty())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("disagrees with its evidence"); + } + + @Test + @DisplayName( + "the platform exception says only what the redacted summary says, and keeps the cause") + void platformExceptionMessageIsTheRedactedSummary() { + GrpcFailureContext context = + new GrpcFailureContext( + CREATE, + GrpcStatusCode.INTERNAL, + GrpcFailureCategory.PERMANENT_DEPENDENCY, + sentButUnanswered(), + GrpcCompletionOutcome.COMPLETION_UNKNOWN, + GrpcFailureContext.RetryDisposition.RESOLVE_FIRST, + 1, + Duration.ofMillis(10), + Optional.empty()); + Exception driverFailure = new IllegalStateException("ERROR: duplicate key value violates ..."); + + GrpcPlatformException exception = new GrpcPlatformException(context, driverFailure); + + assertThat(exception).hasMessage(context.redactedSummary()); + assertThat(exception.getMessage()).doesNotContain("duplicate key"); + assertThat(exception).hasCause(driverFailure); + assertThat(exception.requiresReconciliation()).isTrue(); + } + + @Test + @DisplayName("every failure category maps to a canonical status code") + void everyCategoryMapsToAStatusCode() { + assertThat(GrpcFailureCategory.values()) + .allSatisfy(c -> assertThat(c.statusCode()).isNotNull()); + assertThat(GrpcFailureCategory.VALIDATION.statusCode()) + .isEqualTo(GrpcStatusCode.INVALID_ARGUMENT); + assertThat(GrpcFailureCategory.TRANSIENT_DEPENDENCY.transientFailure()).isTrue(); + assertThat(GrpcFailureCategory.PERMANENT_DEPENDENCY.transientFailure()).isFalse(); + } + + @Test + @DisplayName("an attempt counter below one is refused") + void attemptIsOneBased() { + assertThatThrownBy( + () -> + new GrpcFailureContext( + CREATE, + GrpcStatusCode.INTERNAL, + GrpcFailureCategory.INTERNAL, + sentButUnanswered(), + GrpcCompletionOutcome.COMPLETION_UNKNOWN, + GrpcFailureContext.RetryDisposition.TERMINAL, + 0, + Duration.ZERO, + Optional.empty())) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/evidence/GrpcExecutionEvidenceTest.java b/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/evidence/GrpcExecutionEvidenceTest.java new file mode 100644 index 00000000..c2cbf52b --- /dev/null +++ b/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/evidence/GrpcExecutionEvidenceTest.java @@ -0,0 +1,120 @@ +package dev.caskeleton.grpc.evidence; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcExecutionEvidenceTest { + + private static final GrpcMethodName CREATE = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument"); + private static final GrpcMethodName WATCH = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/WatchDocuments"); + + @Test + @DisplayName("seeing response headers never promotes the business axis to a confirmed commit") + void responseHeadersAreNotPromotedToCommitConfirmed() { + GrpcExecutionEvidence attempted = + new GrpcExecutionEvidence( + CREATE, + RpcType.UNARY, + GrpcTransportEvidence.SENT_UNCONFIRMED, + GrpcBusinessEvidence.ATTEMPTED, + GrpcStreamEvidence.none()); + + GrpcExecutionEvidence afterHeaders = attempted.withResponseHeadersSeen(); + + assertThat(afterHeaders.transport()).isEqualTo(GrpcTransportEvidence.RESPONSE_HEADERS_SEEN); + assertThat(afterHeaders.business()).isEqualTo(GrpcBusinessEvidence.ATTEMPTED); + assertThat(afterHeaders.transport().serverResponded()).isTrue(); + } + + @Test + @DisplayName("a unary call may not carry stream evidence") + void unaryCallMayNotCarryStreamEvidence() { + assertThatThrownBy( + () -> + new GrpcExecutionEvidence( + CREATE, + RpcType.UNARY, + GrpcTransportEvidence.RESPONSE_MESSAGE_SEEN, + GrpcBusinessEvidence.ATTEMPTED, + new GrpcStreamEvidence.Partial(7L))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unary"); + } + + @Test + @DisplayName("a request that was never sent cannot carry business evidence") + void unsentRequestCannotCarryBusinessEvidence() { + assertThatThrownBy( + () -> + new GrpcExecutionEvidence( + CREATE, + RpcType.UNARY, + GrpcTransportEvidence.NOT_SENT, + GrpcBusinessEvidence.COMMIT_CONFIRMED, + GrpcStreamEvidence.none())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("APPLIED requires a named application acknowledgement, not transport progress") + void appliedRequiresAnApplicationAcknowledgement() { + assertThatThrownBy(() -> new GrpcStreamEvidence.Applied(4L, " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("acknowledgement"); + + GrpcStreamEvidence applied = new GrpcStreamEvidence.Applied(4L, "consumer-checkpoint-store"); + assertThat(applied.delivered()).isTrue(); + } + + @Test + @DisplayName("stream evidence is a closed set of four shapes") + void streamEvidenceIsAClosedSetOfFourShapes() { + assertThat(GrpcStreamEvidence.class.getPermittedSubclasses()) + .extracting(Class::getSimpleName) + .containsExactlyInAnyOrder("None", "Partial", "Applied", "Resumable"); + assertThat(GrpcStreamEvidence.none().delivered()).isFalse(); + } + + @Test + @DisplayName("a delivered stream prefix forbids a whole-call retry") + void deliveredStreamPrefixForbidsWholeCallRetry() { + GrpcExecutionEvidence partial = + new GrpcExecutionEvidence( + WATCH, + RpcType.SERVER_STREAMING, + GrpcTransportEvidence.RESPONSE_MESSAGE_SEEN, + GrpcBusinessEvidence.NONE, + new GrpcStreamEvidence.Partial(12L)); + + assertThat(partial.permitsWholeCallRetry()).isFalse(); + assertThat( + GrpcExecutionEvidence.notStarted(WATCH, RpcType.SERVER_STREAMING) + .permitsWholeCallRetry()) + .isTrue(); + } + + @Test + @DisplayName("an unknown commit is never safe to repeat without a guard") + void unknownCommitIsNeverSafeToRepeat() { + GrpcExecutionEvidence unknown = + GrpcExecutionEvidence.notStarted(CREATE, RpcType.UNARY) + .withResponseHeadersSeen() + .withCommitUnknown(); + + assertThat(unknown.business().safeToRepeatWithoutGuard()).isFalse(); + assertThat(unknown.permitsWholeCallRetry()).isFalse(); + } + + @Test + @DisplayName("evidence and its owning failure must name the same method") + void evidenceCarriesItsOwnMethod() { + assertThat(GrpcExecutionEvidence.notStarted(CREATE, RpcType.UNARY).method()).isEqualTo(CREATE); + } +} diff --git a/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/policy/GrpcMethodPolicyCatalogTest.java b/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/policy/GrpcMethodPolicyCatalogTest.java new file mode 100644 index 00000000..0a253932 --- /dev/null +++ b/src/grpc/grpc-core-api/src/test/java/dev/caskeleton/grpc/policy/GrpcMethodPolicyCatalogTest.java @@ -0,0 +1,183 @@ +package dev.caskeleton.grpc.policy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; +import java.time.Duration; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcMethodPolicyCatalogTest { + + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + private static final GrpcMethodName CREATE = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument"); + private static final GrpcMethodName WATCH = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/WatchDocuments"); + private static final GrpcDeadlineProfile TWO_SECONDS = + GrpcDeadlineProfile.of(Duration.ofSeconds(2)); + + @Test + @DisplayName("the idempotency profile has exactly the six documented values") + void idempotencyProfileHasSixValues() { + assertThat(RpcIdempotencyProfile.values()) + .containsExactly( + RpcIdempotencyProfile.READ_ONLY, + RpcIdempotencyProfile.NATURALLY_IDEMPOTENT, + RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED, + RpcIdempotencyProfile.CONDITIONALLY_IDEMPOTENT, + RpcIdempotencyProfile.NON_IDEMPOTENT, + RpcIdempotencyProfile.STREAMING); + } + + @Test + @DisplayName("only a read-only unary method may be hedged") + void onlyReadOnlyUnaryMayBeHedged() { + assertThat(GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS).hedgingAllowed()).isTrue(); + assertThat(GrpcMethodPolicy.nonIdempotentUnary(CREATE, TWO_SECONDS).hedgingAllowed()).isFalse(); + assertThat(RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED.hedgingAllowed()).isFalse(); + } + + @Test + @DisplayName("attaching an explicit retry policy to a NON_IDEMPOTENT method fails to build") + void nonIdempotentMethodRejectsExplicitRetry() { + assertThatThrownBy( + () -> + new GrpcMethodPolicy( + CREATE, + RpcType.UNARY, + RpcIdempotencyProfile.NON_IDEMPOTENT, + TWO_SECONDS, + WaitForReadyPolicy.DISABLED, + true, + 1024, + 1024)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("NON_IDEMPOTENT") + .hasMessageContaining("explicit retry"); + } + + @Test + @DisplayName("a streaming method rejects a whole-call retry policy") + void streamingMethodRejectsWholeCallRetry() { + assertThatThrownBy( + () -> + new GrpcMethodPolicy( + WATCH, + RpcType.SERVER_STREAMING, + RpcIdempotencyProfile.STREAMING, + TWO_SECONDS, + WaitForReadyPolicy.DISABLED, + true, + 1024, + 1024)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("prefix"); + } + + @Test + @DisplayName("a unary method may not claim the STREAMING idempotency profile") + void unaryMethodMayNotClaimTheStreamingProfile() { + assertThatThrownBy( + () -> + new GrpcMethodPolicy( + GET, + RpcType.UNARY, + RpcIdempotencyProfile.STREAMING, + TWO_SECONDS, + WaitForReadyPolicy.DISABLED, + false, + 1024, + 1024)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("STREAMING profile"); + } + + @Test + @DisplayName("every Stable method carries a deadline profile") + void everyStableMethodCarriesADeadline() { + assertThatThrownBy( + () -> + new GrpcMethodPolicy( + GET, + RpcType.UNARY, + RpcIdempotencyProfile.READ_ONLY, + null, + WaitForReadyPolicy.DISABLED, + false, + 1024, + 1024)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("deadline profile"); + } + + @Test + @DisplayName("wait-for-ready is off by default") + void waitForReadyIsOffByDefault() { + assertThat(GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS).waitForReady()) + .isEqualTo(WaitForReadyPolicy.DISABLED); + assertThat(WaitForReadyPolicy.DISABLED.queues()).isFalse(); + assertThat(WaitForReadyPolicy.WORKER_OPT_IN.queues()).isTrue(); + } + + @Test + @DisplayName("registering the same method twice fails rather than picking a winner") + void duplicateRegistrationFails() { + GrpcMethodPolicyCatalog.Builder builder = + GrpcMethodPolicyCatalog.builder() + .register(GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS)); + + assertThatThrownBy(() -> builder.register(GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("duplicate method policy"); + } + + @Test + @DisplayName("a policy for a method the schema does not declare fails the catalog build") + void policyForAnUndeclaredMethodFailsTheBuild() { + GrpcMethodPolicyCatalog.Builder builder = + GrpcMethodPolicyCatalog.builder() + .withDescriptorMethods(Set.of(GET)) + .register(GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS)) + .register(GrpcMethodPolicy.nonIdempotentUnary(CREATE, TWO_SECONDS)); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("CreateDocument"); + } + + @Test + @DisplayName("a method with no policy is refused rather than given defaults") + void missingPolicyIsRefusedRatherThanDefaulted() { + GrpcMethodPolicyCatalog catalog = + GrpcMethodPolicyCatalog.builder() + .register(GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS)) + .build(); + + assertThat(catalog.find(GET)).isPresent(); + assertThat(catalog.find(CREATE)).isEmpty(); + assertThat(catalog.size()).isEqualTo(1); + assertThatThrownBy(() -> catalog.require(CREATE)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("no deadline and no idempotency profile"); + } + + @Test + @DisplayName("the descriptor and the catalog agree on one canonical spelling of a method") + void descriptorAndCatalogShareOneCanonicalName() { + GrpcMethodPolicyCatalog catalog = + GrpcMethodPolicyCatalog.builder() + .withDescriptorMethods(Set.of(GET, CREATE)) + .register(GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS)) + .build(); + + assertThat(catalog.methods()) + .extracting(GrpcMethodName::canonical) + .containsExactly("hyeonworks.document.v1.DocumentService/GetDocument"); + } +} diff --git a/src/grpc/grpc-discovery/build.gradle b/src/grpc/grpc-discovery/build.gradle new file mode 100644 index 00000000..557eec8c --- /dev/null +++ b/src/grpc/grpc-discovery/build.gradle @@ -0,0 +1,9 @@ +apply plugin: 'java-library' + +// Stable discovery: Static/DNS resolvers, pick_first/round_robin load balancing, and the +// Kubernetes VIP / headless / mesh routing profiles. Custom resolvers, custom load balancers and +// xDS are Advanced and are refused here by GrpcDiscoveryPolicyValidator. +dependencies { + api project(':grpc:grpc-core-api') + api project(':grpc:grpc-client') +} diff --git a/src/grpc/grpc-discovery/gradle.lockfile b/src/grpc/grpc-discovery/gradle.lockfile new file mode 100644 index 00000000..9369e831 --- /dev/null +++ b/src/grpc/grpc-discovery/gradle.lockfile @@ -0,0 +1,91 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty= diff --git a/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcDiscoveryPolicyValidator.java b/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcDiscoveryPolicyValidator.java new file mode 100644 index 00000000..d96e4f1c --- /dev/null +++ b/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcDiscoveryPolicyValidator.java @@ -0,0 +1,67 @@ +package dev.caskeleton.grpc.discovery; + +import java.util.ArrayList; +import java.util.List; + +/** + * Checks a discovery configuration for the things that look right and are not. + * + *

Also refuses Advanced schemes by name. {@code xds:///} in a Stable profile is not a + * configuration mistake to warn about — it is a capability with its own control plane, its own + * failure modes and its own promotion gate, and letting it through here would make "Stable supports + * DNS and static" untrue. + */ +public final class GrpcDiscoveryPolicyValidator { + + /** Schemes that exist but belong to Advanced capabilities. */ + private static final List ADVANCED_SCHEMES = List.of("xds", "consul", "etcd", "eureka"); + + private GrpcDiscoveryPolicyValidator() {} + + /** + * Every problem with {@code profile}. + * + * @return an empty list when the configuration does what it claims + */ + public static List violations(GrpcResolverProfile profile) { + if (profile == null) { + throw new IllegalArgumentException("a resolver profile is required"); + } + List violations = new ArrayList<>(); + if (!profile.loadBalancingEffective()) { + violations.add( + profile.loadBalancingPolicy() + + " over " + + profile.expectedAddressCount() + + " address distributes nothing; use " + + GrpcStableLoadBalancer.recommendedFor(profile.expectedAddressCount()) + + " or a resolver that returns several endpoints"); + } + return List.copyOf(violations); + } + + /** + * Refuses a target whose scheme is not Stable. + * + * @throws IllegalArgumentException naming the Advanced capability the scheme belongs to + */ + public static GrpcResolverType requireStableScheme(String scheme) { + if (scheme == null || scheme.isBlank()) { + throw new IllegalArgumentException("a target carries a resolver scheme"); + } + if (ADVANCED_SCHEMES.contains(scheme)) { + throw new IllegalArgumentException( + "resolver scheme '" + + scheme + + "' is an Advanced capability with its own control plane and promotion gate; it is " + + "not part of the Stable DNS and static support"); + } + return GrpcResolverType.forScheme(scheme) + .orElseThrow( + () -> + new IllegalArgumentException( + "unknown resolver scheme '" + + scheme + + "'; Stable schemes are dns, static and unix")); + } +} diff --git a/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcKubernetesProfile.java b/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcKubernetesProfile.java new file mode 100644 index 00000000..90c38005 --- /dev/null +++ b/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcKubernetesProfile.java @@ -0,0 +1,90 @@ +package dev.caskeleton.grpc.discovery; + +import dev.caskeleton.grpc.resilience.GrpcRetryOwner; +import java.time.Duration; + +/** + * A Kubernetes deployment's routing decision, with the stream obligations that come with it. + * + *

{@code streamReconnectBudget} is required whenever the profile carries long streams, because a + * stream pins a client to one pod for its whole life. Every rollout, every eviction and every + * scale-down ends that stream, and a deployment that has not decided how a client reconnects has + * decided that it will not. + */ +public record GrpcKubernetesProfile( + GrpcKubernetesRoutingMode routingMode, + GrpcRetryOwner retryOwner, + boolean carriesLongLivedStreams, + Duration streamReconnectBudget, + Duration readinessDrainGrace) { + + /** Refuses a profile whose retry owner or drain contract contradicts its routing. */ + public GrpcKubernetesProfile { + if (routingMode == null || retryOwner == null) { + throw new IllegalArgumentException("a Kubernetes profile names its routing and retry owner"); + } + if (routingMode.meshOwnsRouting() && retryOwner.explicitRetryInProcess()) { + throw new IllegalArgumentException( + "routing mode " + + routingMode + + " puts retries in the sidecar; an in-process retry owner of " + + retryOwner + + " would multiply every failed call"); + } + if (streamReconnectBudget == null || readinessDrainGrace == null) { + throw new IllegalArgumentException("both durations must be present"); + } + if (streamReconnectBudget.isNegative() || readinessDrainGrace.isNegative()) { + throw new IllegalArgumentException("durations must not be negative"); + } + if (carriesLongLivedStreams && streamReconnectBudget.isZero()) { + throw new IllegalArgumentException( + "a profile with long-lived streams needs a reconnect budget; a stream is pinned to one " + + "pod and every rollout ends it"); + } + if (carriesLongLivedStreams && readinessDrainGrace.isZero()) { + throw new IllegalArgumentException( + "a profile with long-lived streams needs a readiness drain grace; without one a pod stops " + + "serving before its streams have been told to reconnect"); + } + } + + /** A VIP profile for short unary traffic. */ + public static GrpcKubernetesProfile virtualIp() { + return new GrpcKubernetesProfile( + GrpcKubernetesRoutingMode.K8S_VIP, + GrpcRetryOwner.GRPC_PLATFORM, + false, + Duration.ZERO, + Duration.ofSeconds(10)); + } + + /** A headless profile carrying long-lived subscriptions. */ + public static GrpcKubernetesProfile headlessStreaming() { + return new GrpcKubernetesProfile( + GrpcKubernetesRoutingMode.K8S_HEADLESS, + GrpcRetryOwner.GRPC_PLATFORM, + true, + Duration.ofSeconds(5), + Duration.ofSeconds(30)); + } + + /** A mesh-routed profile, where the sidecar owns retries. */ + public static GrpcKubernetesProfile mesh() { + return new GrpcKubernetesProfile( + GrpcKubernetesRoutingMode.MESH, + GrpcRetryOwner.SERVICE_MESH, + false, + Duration.ZERO, + Duration.ofSeconds(10)); + } + + /** The resolver profile this routing mode implies. */ + public GrpcResolverProfile resolverProfile(int expectedAddressCount) { + return new GrpcResolverProfile( + GrpcResolverType.DNS, + routingMode.loadBalancingPolicy(), + Duration.ofSeconds(30), + expectedAddressCount); + } +} diff --git a/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcKubernetesProfileValidator.java b/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcKubernetesProfileValidator.java new file mode 100644 index 00000000..c68b9b61 --- /dev/null +++ b/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcKubernetesProfileValidator.java @@ -0,0 +1,61 @@ +package dev.caskeleton.grpc.discovery; + +import java.util.ArrayList; +import java.util.List; + +/** + * Checks a Kubernetes routing profile against the resolver it implies and the retry owner it + * requires. + * + *

Separate from {@link GrpcDiscoveryPolicyValidator} because the two answer different questions. + * The resolver validator asks whether a load-balancing policy does anything over the addresses it + * will see; this one asks whether the deployment shape, the retry owner and the stream obligations + * agree with each other. A deployment can have a perfectly coherent resolver profile and still have + * put retries in two places. + */ +public final class GrpcKubernetesProfileValidator { + + private GrpcKubernetesProfileValidator() {} + + /** + * Every disagreement inside {@code profile}, and between it and the addresses it will resolve. + * + * @param expectedAddressCount how many endpoints the target resolves to + * @return an empty list when the profile is coherent + */ + public static List violations(GrpcKubernetesProfile profile, int expectedAddressCount) { + if (profile == null) { + throw new IllegalArgumentException("a Kubernetes profile is required"); + } + List violations = + new ArrayList<>( + GrpcDiscoveryPolicyValidator.violations(profile.resolverProfile(expectedAddressCount))); + + if (profile.retryOwner() != profile.routingMode().requiredRetryOwner()) { + violations.add( + "routing mode " + + profile.routingMode() + + " requires retry owner " + + profile.routingMode().requiredRetryOwner() + + " but the profile names " + + profile.retryOwner()); + } + if (profile.carriesLongLivedStreams() + && profile.routingMode() == GrpcKubernetesRoutingMode.K8S_VIP) { + violations.add( + "a VIP routes per connection, so every long-lived stream from one client lands on one pod " + + "and stays there; a streaming profile wants a headless record or an explicit " + + "decision to accept that"); + } + if (profile.carriesLongLivedStreams() + && profile.readinessDrainGrace().compareTo(profile.streamReconnectBudget()) < 0) { + violations.add( + "the readiness drain grace (" + + profile.readinessDrainGrace() + + ") is shorter than the stream reconnect budget (" + + profile.streamReconnectBudget() + + "); the pod stops serving before its clients have finished reconnecting elsewhere"); + } + return List.copyOf(violations); + } +} diff --git a/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcKubernetesRoutingMode.java b/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcKubernetesRoutingMode.java new file mode 100644 index 00000000..682e448f --- /dev/null +++ b/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcKubernetesRoutingMode.java @@ -0,0 +1,45 @@ +package dev.caskeleton.grpc.discovery; + +import dev.caskeleton.grpc.client.GrpcLoadBalancingPolicy; +import dev.caskeleton.grpc.resilience.GrpcRetryOwner; + +/** + * How a Kubernetes deployment routes gRPC, and what each way implies. + * + *

The three differ in who balances. A Service VIP balances per connection in kube-proxy, which + * for a long-lived HTTP/2 connection means it does not balance at all after the first request; a + * headless record moves that job to the client; a mesh takes it back into a sidecar and, with it, + * ownership of retries. Choosing without naming the mode is how a deployment ends up with two + * retriers or none. + */ +public enum GrpcKubernetesRoutingMode { + /** A Service ClusterIP. One virtual address; kube-proxy picks the pod at connect time. */ + K8S_VIP(GrpcLoadBalancingPolicy.PICK_FIRST, false), + /** A headless Service. DNS returns pod addresses and the client balances. */ + K8S_HEADLESS(GrpcLoadBalancingPolicy.ROUND_ROBIN, false), + /** A service mesh sidecar routes and retries. */ + MESH(GrpcLoadBalancingPolicy.PICK_FIRST, true); + + private final GrpcLoadBalancingPolicy loadBalancingPolicy; + private final boolean meshOwnsRouting; + + GrpcKubernetesRoutingMode(GrpcLoadBalancingPolicy loadBalancingPolicy, boolean meshOwnsRouting) { + this.loadBalancingPolicy = loadBalancingPolicy; + this.meshOwnsRouting = meshOwnsRouting; + } + + /** The load balancing policy this mode implies. */ + public GrpcLoadBalancingPolicy loadBalancingPolicy() { + return loadBalancingPolicy; + } + + /** Whether a sidecar owns routing, and therefore retries. */ + public boolean meshOwnsRouting() { + return meshOwnsRouting; + } + + /** The retry owner this mode requires. */ + public GrpcRetryOwner requiredRetryOwner() { + return meshOwnsRouting ? GrpcRetryOwner.SERVICE_MESH : GrpcRetryOwner.GRPC_PLATFORM; + } +} diff --git a/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcResolverProfile.java b/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcResolverProfile.java new file mode 100644 index 00000000..aeb46eb0 --- /dev/null +++ b/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcResolverProfile.java @@ -0,0 +1,63 @@ +package dev.caskeleton.grpc.discovery; + +import dev.caskeleton.grpc.client.GrpcLoadBalancingPolicy; +import java.time.Duration; + +/** + * One channel's discovery settings. + * + *

{@code refreshInterval} is here because DNS caching is where a rolling deployment goes wrong + * quietly. A channel that resolved once at startup keeps sending to addresses that stopped existing + * an hour ago; the calls fail with {@code UNAVAILABLE} and the deployment looks unhealthy long + * after it finished. + */ +public record GrpcResolverProfile( + GrpcResolverType resolverType, + GrpcLoadBalancingPolicy loadBalancingPolicy, + Duration refreshInterval, + int expectedAddressCount) { + + /** Refuses a combination that cannot do what it says. */ + public GrpcResolverProfile { + if (resolverType == null || loadBalancingPolicy == null) { + throw new IllegalArgumentException("a resolver profile names its resolver and balancer"); + } + if (refreshInterval == null || refreshInterval.isNegative()) { + throw new IllegalArgumentException("a refresh interval must be present and non-negative"); + } + if (expectedAddressCount < 1) { + throw new IllegalArgumentException("a target resolves to at least one address"); + } + if (expectedAddressCount > 1 && !resolverType.canReturnMultipleAddresses()) { + throw new IllegalArgumentException( + resolverType + + " returns a single endpoint; it cannot resolve to " + + expectedAddressCount); + } + if (resolverType == GrpcResolverType.DNS && refreshInterval.isZero()) { + throw new IllegalArgumentException( + "a DNS profile without a refresh interval resolves once at startup and keeps sending to " + + "addresses that stopped existing"); + } + } + + /** A single VIP behind DNS. */ + public static GrpcResolverProfile virtualIp() { + return new GrpcResolverProfile( + GrpcResolverType.DNS, GrpcLoadBalancingPolicy.PICK_FIRST, Duration.ofSeconds(30), 1); + } + + /** A headless DNS record with several pods behind it. */ + public static GrpcResolverProfile headless(int expectedAddressCount) { + return new GrpcResolverProfile( + GrpcResolverType.DNS, + GrpcLoadBalancingPolicy.ROUND_ROBIN, + Duration.ofSeconds(30), + expectedAddressCount); + } + + /** Whether the balancer actually spreads traffic over the expected endpoints. */ + public boolean loadBalancingEffective() { + return GrpcStableLoadBalancer.effective(loadBalancingPolicy, expectedAddressCount); + } +} diff --git a/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcResolverType.java b/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcResolverType.java new file mode 100644 index 00000000..ac973941 --- /dev/null +++ b/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcResolverType.java @@ -0,0 +1,45 @@ +package dev.caskeleton.grpc.discovery; + +/** + * The name resolvers the Stable platform supports. + * + *

{@code multipleAddresses} is the property everything else keys on. A resolver that returns one + * address makes {@code round_robin} a no-op, and the pairing is the most common way a deployment + * has load balancing on paper and none in practice. + */ +public enum GrpcResolverType { + /** A fixed address list from configuration. */ + STATIC("static", true), + /** DNS. Returns several addresses for a headless record and one for a VIP. */ + DNS("dns", true), + /** A Unix domain socket. One endpoint by construction. */ + UNIX("unix", false); + + private final String scheme; + private final boolean canReturnMultipleAddresses; + + GrpcResolverType(String scheme, boolean canReturnMultipleAddresses) { + this.scheme = scheme; + this.canReturnMultipleAddresses = canReturnMultipleAddresses; + } + + /** The URI scheme this resolver answers to. */ + public String scheme() { + return scheme; + } + + /** Whether this resolver can ever return more than one address. */ + public boolean canReturnMultipleAddresses() { + return canReturnMultipleAddresses; + } + + /** The Stable resolver for a scheme, or empty when the scheme is Advanced or unknown. */ + public static java.util.Optional forScheme(String scheme) { + for (GrpcResolverType candidate : values()) { + if (candidate.scheme.equals(scheme)) { + return java.util.Optional.of(candidate); + } + } + return java.util.Optional.empty(); + } +} diff --git a/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcStableLoadBalancer.java b/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcStableLoadBalancer.java new file mode 100644 index 00000000..1d89258a --- /dev/null +++ b/src/grpc/grpc-discovery/src/main/java/dev/caskeleton/grpc/discovery/GrpcStableLoadBalancer.java @@ -0,0 +1,38 @@ +package dev.caskeleton.grpc.discovery; + +import dev.caskeleton.grpc.client.GrpcLoadBalancingPolicy; + +/** + * Which load balancer belongs with which resolver. + * + *

A pairing rather than a free choice. The recommendation is not stylistic: {@code round_robin} + * over a single virtual address distributes nothing, and {@code pick_first} over a headless record + * pins every request from this client to one pod, which shows up as one instance at capacity while + * the rest are idle. + */ +public final class GrpcStableLoadBalancer { + + private GrpcStableLoadBalancer() {} + + /** The policy that fits a resolver returning {@code addressCount} endpoints. */ + public static GrpcLoadBalancingPolicy recommendedFor(int addressCount) { + if (addressCount < 1) { + throw new IllegalArgumentException("a resolved target has at least one address"); + } + return addressCount == 1 + ? GrpcLoadBalancingPolicy.PICK_FIRST + : GrpcLoadBalancingPolicy.ROUND_ROBIN; + } + + /** + * Whether {@code policy} does anything useful over {@code addressCount} endpoints. + * + *

False for round-robin over one address, which is the case worth catching. + */ + public static boolean effective(GrpcLoadBalancingPolicy policy, int addressCount) { + if (policy == null) { + throw new IllegalArgumentException("a policy is required"); + } + return !policy.requiresMultipleAddresses() || addressCount > 1; + } +} diff --git a/src/grpc/grpc-discovery/src/test/java/dev/caskeleton/grpc/discovery/GrpcDiscoveryPolicyValidatorTest.java b/src/grpc/grpc-discovery/src/test/java/dev/caskeleton/grpc/discovery/GrpcDiscoveryPolicyValidatorTest.java new file mode 100644 index 00000000..88138922 --- /dev/null +++ b/src/grpc/grpc-discovery/src/test/java/dev/caskeleton/grpc/discovery/GrpcDiscoveryPolicyValidatorTest.java @@ -0,0 +1,93 @@ +package dev.caskeleton.grpc.discovery; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.client.GrpcLoadBalancingPolicy; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcDiscoveryPolicyValidatorTest { + + @Test + @DisplayName("the Stable resolvers are static, DNS and unix") + void theStableResolversAreThree() { + assertThat(GrpcResolverType.values()) + .containsExactly(GrpcResolverType.STATIC, GrpcResolverType.DNS, GrpcResolverType.UNIX); + assertThat(GrpcDiscoveryPolicyValidator.requireStableScheme("dns")) + .isEqualTo(GrpcResolverType.DNS); + assertThat(GrpcDiscoveryPolicyValidator.requireStableScheme("static")) + .isEqualTo(GrpcResolverType.STATIC); + } + + @Test + @DisplayName("xDS and other control-plane schemes are refused as Advanced") + void xdsIsRefusedAsAdvanced() { + assertThatThrownBy(() -> GrpcDiscoveryPolicyValidator.requireStableScheme("xds")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Advanced capability"); + assertThatThrownBy(() -> GrpcDiscoveryPolicyValidator.requireStableScheme("consul")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> GrpcDiscoveryPolicyValidator.requireStableScheme("nonsense")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unknown resolver scheme"); + } + + @Test + @DisplayName("a VIP uses pick_first and a headless record uses round_robin") + void recommendationsFollowTheAddressCount() { + assertThat(GrpcStableLoadBalancer.recommendedFor(1)) + .isEqualTo(GrpcLoadBalancingPolicy.PICK_FIRST); + assertThat(GrpcStableLoadBalancer.recommendedFor(4)) + .isEqualTo(GrpcLoadBalancingPolicy.ROUND_ROBIN); + assertThatThrownBy(() -> GrpcStableLoadBalancer.recommendedFor(0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("round-robin over one endpoint is reported as distributing nothing") + void roundRobinOverOneEndpointIsReported() { + GrpcResolverProfile misconfigured = + new GrpcResolverProfile( + GrpcResolverType.DNS, GrpcLoadBalancingPolicy.ROUND_ROBIN, Duration.ofSeconds(30), 1); + + assertThat(misconfigured.loadBalancingEffective()).isFalse(); + assertThat(GrpcStableLoadBalancer.effective(GrpcLoadBalancingPolicy.PICK_FIRST, 1)).isTrue(); + assertThat(GrpcDiscoveryPolicyValidator.violations(misconfigured)) + .singleElement() + .satisfies(violation -> assertThat(violation).contains("distributes nothing")); + } + + @Test + @DisplayName("a correctly paired profile passes") + void aCorrectlyPairedProfilePasses() { + assertThat(GrpcDiscoveryPolicyValidator.violations(GrpcResolverProfile.virtualIp())).isEmpty(); + assertThat(GrpcDiscoveryPolicyValidator.violations(GrpcResolverProfile.headless(4))).isEmpty(); + } + + @Test + @DisplayName("a DNS profile that never refreshes is refused") + void aDnsProfileMustRefresh() { + assertThatThrownBy( + () -> + new GrpcResolverProfile( + GrpcResolverType.DNS, GrpcLoadBalancingPolicy.PICK_FIRST, Duration.ZERO, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("stopped existing"); + } + + @Test + @DisplayName("a single-endpoint resolver may not claim several addresses") + void aSingleEndpointResolverCannotClaimSeveral() { + assertThatThrownBy( + () -> + new GrpcResolverProfile( + GrpcResolverType.UNIX, + GrpcLoadBalancingPolicy.ROUND_ROBIN, + Duration.ofSeconds(30), + 3)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("single endpoint"); + } +} diff --git a/src/grpc/grpc-discovery/src/test/java/dev/caskeleton/grpc/discovery/GrpcKubernetesProfileTest.java b/src/grpc/grpc-discovery/src/test/java/dev/caskeleton/grpc/discovery/GrpcKubernetesProfileTest.java new file mode 100644 index 00000000..5ed80178 --- /dev/null +++ b/src/grpc/grpc-discovery/src/test/java/dev/caskeleton/grpc/discovery/GrpcKubernetesProfileTest.java @@ -0,0 +1,127 @@ +package dev.caskeleton.grpc.discovery; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.client.GrpcLoadBalancingPolicy; +import dev.caskeleton.grpc.resilience.GrpcRetryOwner; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcKubernetesProfileTest { + + @Test + @DisplayName("each routing mode implies its balancer and its retry owner") + void routingModesImplyTheirOwners() { + assertThat(GrpcKubernetesRoutingMode.K8S_VIP.loadBalancingPolicy()) + .isEqualTo(GrpcLoadBalancingPolicy.PICK_FIRST); + assertThat(GrpcKubernetesRoutingMode.K8S_HEADLESS.loadBalancingPolicy()) + .isEqualTo(GrpcLoadBalancingPolicy.ROUND_ROBIN); + assertThat(GrpcKubernetesRoutingMode.MESH.meshOwnsRouting()).isTrue(); + assertThat(GrpcKubernetesRoutingMode.MESH.requiredRetryOwner()) + .isEqualTo(GrpcRetryOwner.SERVICE_MESH); + assertThat(GrpcKubernetesRoutingMode.K8S_VIP.requiredRetryOwner()) + .isEqualTo(GrpcRetryOwner.GRPC_PLATFORM); + } + + @Test + @DisplayName("a mesh profile that also retries in-process is refused at construction") + void aMeshProfileMayNotAlsoRetryInProcess() { + assertThatThrownBy( + () -> + new GrpcKubernetesProfile( + GrpcKubernetesRoutingMode.MESH, + GrpcRetryOwner.APPLICATION, + false, + Duration.ZERO, + Duration.ofSeconds(10))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("multiply every failed call"); + } + + @Test + @DisplayName("a profile carrying long streams must state a reconnect budget and a drain grace") + void longStreamsRequireAReconnectContract() { + assertThatThrownBy( + () -> + new GrpcKubernetesProfile( + GrpcKubernetesRoutingMode.K8S_HEADLESS, + GrpcRetryOwner.GRPC_PLATFORM, + true, + Duration.ZERO, + Duration.ofSeconds(30))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("every rollout ends it"); + assertThatThrownBy( + () -> + new GrpcKubernetesProfile( + GrpcKubernetesRoutingMode.K8S_HEADLESS, + GrpcRetryOwner.GRPC_PLATFORM, + true, + Duration.ofSeconds(5), + Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("readiness drain grace"); + } + + @Test + @DisplayName("the three shipped profiles validate against the resolvers they imply") + void theShippedProfilesValidate() { + assertThat(GrpcKubernetesProfileValidator.violations(GrpcKubernetesProfile.virtualIp(), 1)) + .isEmpty(); + assertThat( + GrpcKubernetesProfileValidator.violations(GrpcKubernetesProfile.headlessStreaming(), 4)) + .isEmpty(); + assertThat(GrpcKubernetesProfileValidator.violations(GrpcKubernetesProfile.mesh(), 1)) + .isEmpty(); + } + + @Test + @DisplayName("a headless profile resolving to one address is reported as distributing nothing") + void aHeadlessProfileWithOneAddressIsReported() { + assertThat( + GrpcKubernetesProfileValidator.violations(GrpcKubernetesProfile.headlessStreaming(), 1)) + .anySatisfy(violation -> assertThat(violation).contains("distributes nothing")); + } + + @Test + @DisplayName("a VIP profile carrying long streams is reported, because a VIP pins each client") + void aVipProfileWithLongStreamsIsReported() { + GrpcKubernetesProfile vipWithStreams = + new GrpcKubernetesProfile( + GrpcKubernetesRoutingMode.K8S_VIP, + GrpcRetryOwner.GRPC_PLATFORM, + true, + Duration.ofSeconds(5), + Duration.ofSeconds(30)); + + assertThat(GrpcKubernetesProfileValidator.violations(vipWithStreams, 1)) + .anySatisfy(violation -> assertThat(violation).contains("lands on one pod")); + } + + @Test + @DisplayName("a drain grace shorter than the reconnect budget is reported") + void aTooShortDrainGraceIsReported() { + GrpcKubernetesProfile tightGrace = + new GrpcKubernetesProfile( + GrpcKubernetesRoutingMode.K8S_HEADLESS, + GrpcRetryOwner.GRPC_PLATFORM, + true, + Duration.ofSeconds(30), + Duration.ofSeconds(5)); + + assertThat(GrpcKubernetesProfileValidator.violations(tightGrace, 4)) + .anySatisfy(violation -> assertThat(violation).contains("finished reconnecting elsewhere")); + } + + @Test + @DisplayName("a routing mode implies the resolver profile its balancer needs") + void aRoutingModeImpliesItsResolverProfile() { + GrpcResolverProfile headless = GrpcKubernetesProfile.headlessStreaming().resolverProfile(4); + + assertThat(headless.resolverType()).isEqualTo(GrpcResolverType.DNS); + assertThat(headless.loadBalancingPolicy()).isEqualTo(GrpcLoadBalancingPolicy.ROUND_ROBIN); + assertThat(headless.loadBalancingEffective()).isTrue(); + } +} diff --git a/src/grpc/grpc-observability/build.gradle b/src/grpc/grpc-observability/build.gradle new file mode 100644 index 00000000..a56d45f3 --- /dev/null +++ b/src/grpc/grpc-observability/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'java-library' + +// Bounded observability: logical RPC vs physical attempt vs stream lifecycle, with a cardinality +// policy that refuses payload, raw metadata and any actor/tenant/object/stream/idempotency +// identifier as a tag. +dependencies { + api project(':grpc:grpc-core-api') + + // api: the observation convention's public signatures name Micrometer types, so wiring it + // requires naming them. + api 'io.micrometer:micrometer-core' +} diff --git a/src/grpc/grpc-observability/gradle.lockfile b/src/grpc/grpc-observability/gradle.lockfile new file mode 100644 index 00000000..f5ad1bb5 --- /dev/null +++ b/src/grpc/grpc-observability/gradle.lockfile @@ -0,0 +1,89 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty= diff --git a/src/grpc/grpc-observability/src/main/java/dev/caskeleton/grpc/observability/GrpcMetricCardinalityPolicy.java b/src/grpc/grpc-observability/src/main/java/dev/caskeleton/grpc/observability/GrpcMetricCardinalityPolicy.java new file mode 100644 index 00000000..a48db400 --- /dev/null +++ b/src/grpc/grpc-observability/src/main/java/dev/caskeleton/grpc/observability/GrpcMetricCardinalityPolicy.java @@ -0,0 +1,123 @@ +package dev.caskeleton.grpc.observability; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Which tags may go on a metric, and which may never. + * + *

An allowlist, because the failure is not a bad tag — it is an unbounded one. A tag whose value + * space grows with traffic multiplies every time series by that space: one tenant id turns a + * hundred series into a hundred thousand, and the metric backend either drops the data or bills for + * it. By the time anyone notices, the dashboards built on those metrics are already gone. + * + *

The forbidden list is separate from "not on the allowlist" on purpose. Everything unlisted is + * refused anyway; naming the dangerous ones gives the refusal a message that says why rather than + * just that. + */ +public final class GrpcMetricCardinalityPolicy { + + /** The tags a bounded metric may carry. */ + private static final Set ALLOWED_TAGS = + Set.of( + "grpc.service", + "grpc.method", + "grpc.rpc_type", + "grpc.status", + "grpc.channel_profile", + "grpc.completion_outcome", + "grpc.retry_bucket", + "grpc.stream_termination_reason"); + + /** Tags whose value space grows with traffic, or which carry content. */ + private static final Set FORBIDDEN_TAGS = + Set.of( + "grpc.actor_id", + "grpc.tenant_id", + "grpc.object_id", + "grpc.stream_id", + "grpc.idempotency_key", + "grpc.request", + "grpc.response", + "grpc.metadata", + "grpc.authorization", + "grpc.error_detail", + "grpc.trace_id"); + + private static final Pattern UNBOUNDED_VALUE = + Pattern.compile( + "(?i).*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|sha256:|bearer ).*"); + + /** The retry buckets, so attempt counts do not become a tag value per attempt. */ + private static final List RETRY_BUCKETS = List.of("0", "1", "2", "3+"); + + private GrpcMetricCardinalityPolicy() {} + + /** The allowed tag keys. */ + public static Set allowedTags() { + return ALLOWED_TAGS; + } + + /** The explicitly forbidden tag keys. */ + public static Set forbiddenTags() { + return FORBIDDEN_TAGS; + } + + /** + * Every problem with a proposed tag set. + * + * @return an empty list when the tags are bounded and none carries content + */ + public static List violations(Map tags) { + if (tags == null) { + throw new IllegalArgumentException("a tag set is required"); + } + List violations = new ArrayList<>(); + tags.forEach( + (key, value) -> { + if (FORBIDDEN_TAGS.contains(key)) { + violations.add( + "tag '" + + key + + "' is forbidden: its value space grows with traffic, so every series is " + + "multiplied by it"); + return; + } + if (!ALLOWED_TAGS.contains(key)) { + violations.add( + "tag '" + + key + + "' is not on the bounded allowlist " + + ALLOWED_TAGS.stream().sorted().toList()); + return; + } + if (value != null && UNBOUNDED_VALUE.matcher(value).matches()) { + violations.add( + "tag '" + key + "' carries a value that looks like an identifier or a credential"); + } + }); + return List.copyOf(violations); + } + + /** + * The bucket an attempt number belongs in. + * + *

Bucketed rather than tagged directly, because an attempt count is unbounded in principle and + * the distinction anyone acts on is first attempt, one retry, several. + */ + public static String retryBucket(int attempt) { + if (attempt < 1) { + throw new IllegalArgumentException("attempt is 1-based"); + } + int retries = attempt - 1; + return retries >= 3 ? "3+" : RETRY_BUCKETS.get(retries); + } + + /** Every retry bucket, so a dashboard can enumerate them. */ + public static List retryBuckets() { + return RETRY_BUCKETS; + } +} diff --git a/src/grpc/grpc-observability/src/main/java/dev/caskeleton/grpc/observability/GrpcObservationConvention.java b/src/grpc/grpc-observability/src/main/java/dev/caskeleton/grpc/observability/GrpcObservationConvention.java new file mode 100644 index 00000000..9dd6d3b6 --- /dev/null +++ b/src/grpc/grpc-observability/src/main/java/dev/caskeleton/grpc/observability/GrpcObservationConvention.java @@ -0,0 +1,99 @@ +package dev.caskeleton.grpc.observability; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.Timer; +import java.util.List; +import java.util.Map; + +/** + * Registers gRPC measurements against Micrometer under one naming and tagging convention. + * + *

Every registration goes through {@link GrpcMetricCardinalityPolicy} first, and a violation + * throws rather than being dropped. Dropping would be gentler at runtime and worse in practice: the + * unbounded tag would keep being written by whatever added it, and the first symptom would be the + * metric backend refusing the series in production. + * + *

Attempts are recorded as counter increments under the logical call's tags rather than as their + * own timed calls, which is what keeps a retried-and-succeeded call from reading as two failures + * and a success. + */ +public final class GrpcObservationConvention { + + /** Timer for a logical RPC, retries included. */ + public static final String RPC_DURATION = "grpc.rpc.duration"; + + /** Counter of physical attempts, including the first. */ + public static final String RPC_ATTEMPTS = "grpc.rpc.attempts"; + + /** Counter of calls whose business outcome could not be determined. */ + public static final String COMPLETION_UNKNOWN = "grpc.rpc.completion_unknown"; + + /** Timer for how long a call waited for a ready channel. */ + public static final String QUEUE_WAIT = "grpc.rpc.queue_wait"; + + /** Timer for a stream's whole lifetime. */ + public static final String STREAM_LIFETIME = "grpc.stream.lifetime"; + + /** Counter of messages a stream sent. */ + public static final String STREAM_MESSAGES = "grpc.stream.messages"; + + /** Counter of times a stream writer paused waiting for the transport. */ + public static final String STREAM_FLOW_CONTROL_STALLS = "grpc.stream.flow_control_stalls"; + + private final MeterRegistry registry; + + /** Binds the convention to a registry. */ + public GrpcObservationConvention(MeterRegistry registry) { + if (registry == null) { + throw new IllegalArgumentException("an observation convention needs a meter registry"); + } + this.registry = registry; + } + + /** Records one logical RPC and its attempts. */ + public void record(GrpcRpcObservation observation) { + if (observation == null) { + throw new IllegalArgumentException("an observation is required"); + } + Tags tags = boundedTags(observation.tags()); + Timer.builder(RPC_DURATION).tags(tags).register(registry).record(observation.duration()); + registry.counter(RPC_ATTEMPTS, tags).increment(observation.attempts()); + if (observation.completionOutcome().requiresReconciliation()) { + registry.counter(COMPLETION_UNKNOWN, tags).increment(); + } + if (!observation.queueWaitTime().isZero()) { + Timer.builder(QUEUE_WAIT).tags(tags).register(registry).record(observation.queueWaitTime()); + } + } + + /** Records one stream's lifetime. */ + public void record(GrpcStreamObservation observation) { + if (observation == null) { + throw new IllegalArgumentException("an observation is required"); + } + Tags tags = boundedTags(observation.tags()); + Timer.builder(STREAM_LIFETIME).tags(tags).register(registry).record(observation.lifetime()); + // Explicit widening: a long counter delta is a double to Micrometer, and leaving the + // conversion implicit is a precision question the compiler is right to ask about. + registry.counter(STREAM_MESSAGES, tags).increment((double) observation.messagesSent()); + registry + .counter(STREAM_FLOW_CONTROL_STALLS, tags) + .increment((double) observation.flowControlStalls()); + } + + /** + * Converts a tag map after checking it. + * + * @throws IllegalArgumentException naming every unbounded or content-bearing tag + */ + public static Tags boundedTags(Map tags) { + List violations = GrpcMetricCardinalityPolicy.violations(tags); + if (!violations.isEmpty()) { + throw new IllegalArgumentException( + "refusing to register a metric with unbounded or content-bearing tags: " + violations); + } + return Tags.of(tags.entrySet().stream().map(e -> Tag.of(e.getKey(), e.getValue())).toList()); + } +} diff --git a/src/grpc/grpc-observability/src/main/java/dev/caskeleton/grpc/observability/GrpcRpcObservation.java b/src/grpc/grpc-observability/src/main/java/dev/caskeleton/grpc/observability/GrpcRpcObservation.java new file mode 100644 index 00000000..470142e6 --- /dev/null +++ b/src/grpc/grpc-observability/src/main/java/dev/caskeleton/grpc/observability/GrpcRpcObservation.java @@ -0,0 +1,78 @@ +package dev.caskeleton.grpc.observability; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.GrpcStatusCode; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.error.GrpcCompletionOutcome; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +/** + * One RPC, measured at the level a caller cares about. + * + *

The logical call, not the attempt. A retried call is one observation with a retry bucket, and + * three attempt events beneath it; recording three separate calls instead makes the success rate + * read as 33% when the caller in fact got its answer. The two levels answer different questions and + * keeping them apart is what makes either usable. + * + *

{@code deadlineRemaining} and {@code queueWaitTime} are recorded because they are the two + * numbers that explain a latency change without being latency. A p99 that doubles during a rollout + * is a different incident depending on whether callers were queueing. + */ +public record GrpcRpcObservation( + GrpcMethodName method, + RpcType rpcType, + GrpcStatusCode status, + GrpcCompletionOutcome completionOutcome, + String channelProfile, + int attempts, + Duration duration, + Duration deadlineRemaining, + Duration queueWaitTime) { + + /** Requires the identity and non-negative measurements. */ + public GrpcRpcObservation { + if (method == null || rpcType == null || status == null || completionOutcome == null) { + throw new IllegalArgumentException("an observation identifies the call and its outcome"); + } + if (attempts < 1) { + throw new IllegalArgumentException("an observed call has at least one attempt"); + } + if (duration == null || duration.isNegative()) { + throw new IllegalArgumentException("duration must be present and non-negative"); + } + if (deadlineRemaining == null || queueWaitTime == null || queueWaitTime.isNegative()) { + throw new IllegalArgumentException("deadline and queue measurements must be present"); + } + } + + /** + * The bounded tag set for this observation. + * + *

Built here rather than at each meter registration, so there is one answer to what a gRPC + * metric is tagged with and {@link GrpcMetricCardinalityPolicy} has one thing to check. + */ + public Map tags() { + Map tags = new LinkedHashMap<>(); + tags.put("grpc.service", method.service().value()); + tags.put("grpc.method", method.method()); + tags.put("grpc.rpc_type", rpcType.name()); + tags.put("grpc.status", status.name()); + tags.put("grpc.completion_outcome", completionOutcome.name()); + tags.put("grpc.channel_profile", channelProfile == null ? "server" : channelProfile); + tags.put("grpc.retry_bucket", GrpcMetricCardinalityPolicy.retryBucket(attempts)); + return Map.copyOf(tags); + } + + /** Whether this call needed more than one attempt. */ + public boolean retried() { + return attempts > 1; + } + + /** How much of the deadline was left when the call ended, if any. */ + public Optional unusedDeadline() { + return deadlineRemaining.isNegative() ? Optional.empty() : Optional.of(deadlineRemaining); + } +} diff --git a/src/grpc/grpc-observability/src/main/java/dev/caskeleton/grpc/observability/GrpcStreamObservation.java b/src/grpc/grpc-observability/src/main/java/dev/caskeleton/grpc/observability/GrpcStreamObservation.java new file mode 100644 index 00000000..c5628d79 --- /dev/null +++ b/src/grpc/grpc-observability/src/main/java/dev/caskeleton/grpc/observability/GrpcStreamObservation.java @@ -0,0 +1,54 @@ +package dev.caskeleton.grpc.observability; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A stream's lifetime, measured as a lifetime rather than as a call. + * + *

Duration percentiles are meaningless here — a healthy subscription lasts an hour and an + * unhealthy one lasts an hour — so what is recorded instead is what actually distinguishes them: + * how many messages moved, how often the writer stalled waiting for the transport, and how it + * ended. A rise in flow-control stalls is a consumer falling behind, which is invisible in a + * latency metric. + */ +public record GrpcStreamObservation( + GrpcMethodName method, + String channelProfile, + String terminationReason, + long messagesSent, + long flowControlStalls, + long queueHighWatermark, + Duration lifetime) { + + /** Requires the identity and non-negative counters. */ + public GrpcStreamObservation { + if (method == null || terminationReason == null || terminationReason.isBlank()) { + throw new IllegalArgumentException("a stream observation names its method and how it ended"); + } + if (messagesSent < 0 || flowControlStalls < 0 || queueHighWatermark < 0) { + throw new IllegalArgumentException("stream counters must not be negative"); + } + if (lifetime == null || lifetime.isNegative()) { + throw new IllegalArgumentException("a stream observation records how long it lived"); + } + } + + /** The bounded tag set. The stream id is deliberately absent. */ + public Map tags() { + Map tags = new LinkedHashMap<>(); + tags.put("grpc.service", method.service().value()); + tags.put("grpc.method", method.method()); + tags.put("grpc.rpc_type", "SERVER_STREAMING"); + tags.put("grpc.channel_profile", channelProfile == null ? "server" : channelProfile); + tags.put("grpc.stream_termination_reason", terminationReason); + return Map.copyOf(tags); + } + + /** Whether the writer spent time blocked on a consumer that could not keep up. */ + public boolean consumerFellBehind() { + return flowControlStalls > 0; + } +} diff --git a/src/grpc/grpc-observability/src/test/java/dev/caskeleton/grpc/observability/GrpcMetricCardinalityPolicyTest.java b/src/grpc/grpc-observability/src/test/java/dev/caskeleton/grpc/observability/GrpcMetricCardinalityPolicyTest.java new file mode 100644 index 00000000..5dcf37d6 --- /dev/null +++ b/src/grpc/grpc-observability/src/test/java/dev/caskeleton/grpc/observability/GrpcMetricCardinalityPolicyTest.java @@ -0,0 +1,172 @@ +package dev.caskeleton.grpc.observability; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.GrpcStatusCode; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.error.GrpcCompletionOutcome; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcMetricCardinalityPolicyTest { + + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + + private static GrpcRpcObservation observation(int attempts, GrpcCompletionOutcome outcome) { + return new GrpcRpcObservation( + GET, + RpcType.UNARY, + GrpcStatusCode.OK, + outcome, + "documents-read", + attempts, + Duration.ofMillis(120), + Duration.ofMillis(880), + Duration.ZERO); + } + + @Test + @DisplayName("only the bounded tags are allowed") + void onlyBoundedTagsAreAllowed() { + assertThat(GrpcMetricCardinalityPolicy.allowedTags()) + .containsExactlyInAnyOrder( + "grpc.service", + "grpc.method", + "grpc.rpc_type", + "grpc.status", + "grpc.channel_profile", + "grpc.completion_outcome", + "grpc.retry_bucket", + "grpc.stream_termination_reason"); + assertThat( + GrpcMetricCardinalityPolicy.violations( + observation(1, GrpcCompletionOutcome.COMPLETED).tags())) + .isEmpty(); + } + + @Test + @DisplayName("actor, tenant, object, stream and idempotency identifiers are refused") + void unboundedIdentifierTagsAreRefused() { + Map tags = new LinkedHashMap<>(); + tags.put("grpc.tenant_id", "tenant-1"); + tags.put("grpc.actor_id", "actor-1"); + tags.put("grpc.stream_id", "stream-1"); + tags.put("grpc.idempotency_key", "order-1"); + tags.put("grpc.object_id", "document-1"); + + assertThat(GrpcMetricCardinalityPolicy.violations(tags)) + .hasSize(5) + .allSatisfy( + violation -> assertThat(violation).contains("every series is multiplied by it")); + } + + @Test + @DisplayName("payload, metadata and raw error detail are refused") + void contentBearingTagsAreRefused() { + Map tags = new LinkedHashMap<>(); + tags.put("grpc.request", "{...}"); + tags.put("grpc.metadata", "authorization=Bearer x"); + tags.put("grpc.error_detail", "SQLState 23505"); + + assertThat(GrpcMetricCardinalityPolicy.violations(tags)).hasSize(3); + } + + @Test + @DisplayName("an allowed tag carrying an identifier-shaped value is still refused") + void anIdentifierShapedValueIsRefused() { + assertThat( + GrpcMetricCardinalityPolicy.violations( + Map.of("grpc.channel_profile", "8f14e45f-ceea-467a-9a0e-4e2c9a1b3d55"))) + .singleElement() + .satisfies(violation -> assertThat(violation).contains("looks like an identifier")); + assertThat(GrpcMetricCardinalityPolicy.violations(Map.of("grpc.status", "Bearer eyJhbGci"))) + .isNotEmpty(); + } + + @Test + @DisplayName("an unlisted tag is refused with the allowlist in the message") + void unlistedTagsAreRefused() { + assertThat(GrpcMetricCardinalityPolicy.violations(Map.of("grpc.pod_name", "documents-7f9"))) + .singleElement() + .satisfies(violation -> assertThat(violation).contains("bounded allowlist")); + } + + @Test + @DisplayName("attempts are bucketed rather than tagged one per value") + void attemptsAreBucketed() { + assertThat(GrpcMetricCardinalityPolicy.retryBucket(1)).isEqualTo("0"); + assertThat(GrpcMetricCardinalityPolicy.retryBucket(2)).isEqualTo("1"); + assertThat(GrpcMetricCardinalityPolicy.retryBucket(4)).isEqualTo("3+"); + assertThat(GrpcMetricCardinalityPolicy.retryBucket(99)).isEqualTo("3+"); + assertThat(GrpcMetricCardinalityPolicy.retryBuckets()).containsExactly("0", "1", "2", "3+"); + assertThatThrownBy(() -> GrpcMetricCardinalityPolicy.retryBucket(0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a retried call is one observation with a bucket, not three calls") + void aRetriedCallIsOneObservation() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + GrpcObservationConvention convention = new GrpcObservationConvention(registry); + + convention.record(observation(3, GrpcCompletionOutcome.COMPLETED)); + + assertThat(registry.get(GrpcObservationConvention.RPC_DURATION).timer().count()).isEqualTo(1L); + assertThat(registry.get(GrpcObservationConvention.RPC_ATTEMPTS).counter().count()) + .isEqualTo(3.0d); + assertThat( + registry + .get(GrpcObservationConvention.RPC_DURATION) + .timer() + .getId() + .getTag("grpc.retry_bucket")) + .isEqualTo("2"); + } + + @Test + @DisplayName("a completion-unknown call is counted separately") + void completionUnknownIsCountedSeparately() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + GrpcObservationConvention convention = new GrpcObservationConvention(registry); + + convention.record(observation(1, GrpcCompletionOutcome.COMPLETION_UNKNOWN)); + + assertThat(registry.get(GrpcObservationConvention.COMPLETION_UNKNOWN).counter().count()) + .isEqualTo(1.0d); + } + + @Test + @DisplayName("a stream is measured by messages and stalls, and never tagged with its id") + void streamsAreMeasuredByMessagesAndStalls() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + GrpcObservationConvention convention = new GrpcObservationConvention(registry); + GrpcStreamObservation observation = + new GrpcStreamObservation( + GET, "documents-stream", "SLOW_CONSUMER", 1200L, 7L, 250L, Duration.ofMinutes(12)); + + convention.record(observation); + + assertThat(observation.tags()).doesNotContainKey("grpc.stream_id"); + assertThat(observation.consumerFellBehind()).isTrue(); + assertThat(registry.get(GrpcObservationConvention.STREAM_MESSAGES).counter().count()) + .isEqualTo(1200.0d); + assertThat(registry.get(GrpcObservationConvention.STREAM_FLOW_CONTROL_STALLS).counter().count()) + .isEqualTo(7.0d); + } + + @Test + @DisplayName("registering a metric with an unbounded tag throws rather than dropping it") + void anUnboundedTagThrowsRatherThanBeingDropped() { + assertThatThrownBy( + () -> GrpcObservationConvention.boundedTags(Map.of("grpc.tenant_id", "tenant-1"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("refusing to register"); + } +} diff --git a/src/grpc/grpc-operation-ledger-jpa/build.gradle b/src/grpc/grpc-operation-ledger-jpa/build.gradle new file mode 100644 index 00000000..748e3062 --- /dev/null +++ b/src/grpc/grpc-operation-ledger-jpa/build.gradle @@ -0,0 +1,17 @@ +apply plugin: 'java-library' + +// Durable mutation idempotency: the operation ledger entity, its state machine, the vendor-neutral +// repository port and the Spring Data JPA binding, plus the migration that owns the unique +// constraint the whole contract rests on. +// +// The port is what the policy layer consumes; the Spring Data interface is the adapter. Tests here +// run against a hand-rolled in-memory port implementation — a real datastore is only justified when +// vendor semantics are the thing under test, and the constraint is asserted by the migration. +dependencies { + api project(':grpc:grpc-core-api') + + // api: the ledger entity is a public JPA type and the repository interface is a public Spring + // Data type, so both vendor APIs appear in this module's public signatures. + api 'jakarta.persistence:jakarta.persistence-api' + api 'org.springframework.data:spring-data-jpa' +} diff --git a/src/grpc/grpc-operation-ledger-jpa/gradle.lockfile b/src/grpc/grpc-operation-ledger-jpa/gradle.lockfile new file mode 100644 index 00000000..2c386903 --- /dev/null +++ b/src/grpc/grpc-operation-ledger-jpa/gradle.lockfile @@ -0,0 +1,99 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.data:spring-data-commons:4.0.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-jpa:4.0.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jdbc:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-orm:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty= diff --git a/src/grpc/grpc-operation-ledger-jpa/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationLedgerEntity.java b/src/grpc/grpc-operation-ledger-jpa/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationLedgerEntity.java new file mode 100644 index 00000000..fe732dce --- /dev/null +++ b/src/grpc/grpc-operation-ledger-jpa/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationLedgerEntity.java @@ -0,0 +1,155 @@ +package dev.caskeleton.grpc.ledger; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.Optional; + +/** + * The durable form of an operation claim. + * + *

The identity is stored both as the composite key columns and as the derived {@code storageKey} + * primary key. The composite carries the unique constraint that makes the claim atomic; the derived + * key gives lookups a single-column primary key to hit. Storing only one of them would mean either + * a three-column primary key on the hottest path or a uniqueness rule with nothing enforcing it. + * + *

{@code EnumType.STRING}, not ORDINAL. An ordinal column silently re-points every stored row + * when a value is inserted into the enum, and the rows here decide whether a payment runs twice. + */ +@Entity +@Table(name = "grpc_operation_ledger") +public class GrpcOperationLedgerEntity { + + @Id + @Column(name = "storage_key", nullable = false, length = 512) + private String storageKey; + + @Column(name = "caller_fingerprint", nullable = false, length = 256) + private String callerFingerprint; + + @Column(name = "full_method_name", nullable = false, length = 256) + private String fullMethodName; + + @Column(name = "idempotency_key_hash", nullable = false, length = 80) + private String idempotencyKeyHash; + + @Column(name = "request_fingerprint", nullable = false, length = 80) + private String requestFingerprint; + + @Enumerated(EnumType.STRING) + @Column(name = "state", nullable = false, length = 32) + private GrpcOperationLedgerState state; + + @Column(name = "outcome_reference", length = 512) + private String outcomeReference; + + @Column(name = "claimed_at", nullable = false) + private Instant claimedAt; + + @Column(name = "completed_at") + private Instant completedAt; + + /** JPA requires a no-argument constructor. */ + protected GrpcOperationLedgerEntity() { + // for JPA + } + + private GrpcOperationLedgerEntity( + GrpcOperationIdentity identity, String requestFingerprint, Instant claimedAt) { + this.storageKey = identity.storageKey(); + this.callerFingerprint = identity.callerFingerprint(); + this.fullMethodName = identity.method().canonical(); + this.idempotencyKeyHash = identity.idempotencyKeyHash(); + this.requestFingerprint = requestFingerprint; + this.state = GrpcOperationLedgerState.IN_PROGRESS; + this.claimedAt = claimedAt; + } + + /** A new claim, in progress. */ + public static GrpcOperationLedgerEntity claim( + GrpcOperationIdentity identity, String requestFingerprint, Instant claimedAt) { + if (identity == null || claimedAt == null) { + throw new IllegalArgumentException("a claim needs an identity and a moment"); + } + if (requestFingerprint == null || !requestFingerprint.startsWith("sha256:")) { + throw new IllegalArgumentException("a claim stores a hashed request fingerprint"); + } + return new GrpcOperationLedgerEntity(identity, requestFingerprint, claimedAt); + } + + /** The storage key. */ + public String getStorageKey() { + return storageKey; + } + + /** The claim's current state. */ + public GrpcOperationLedgerState getState() { + return state; + } + + /** The hashed request this claim was made for. */ + public String getRequestFingerprint() { + return requestFingerprint; + } + + /** The stored outcome, when the claim committed. */ + public Optional getOutcomeReference() { + return Optional.ofNullable(outcomeReference); + } + + /** When the claim was made. */ + public Instant getClaimedAt() { + return claimedAt; + } + + /** When the claim finished, if it has. */ + public Optional getCompletedAt() { + return Optional.ofNullable(completedAt); + } + + /** + * Records a commit with its replayable outcome. + * + * @throws IllegalStateException when the claim already finished; a second terminal transition + * would overwrite the outcome a retry is entitled to receive + */ + public void markCommitted(String outcomeReference, Instant completedAt) { + requireInProgress(); + if (outcomeReference == null || outcomeReference.isBlank()) { + throw new IllegalArgumentException( + "a commit records the outcome to replay; without it the row cannot answer a retry"); + } + this.state = GrpcOperationLedgerState.COMMITTED; + this.outcomeReference = outcomeReference; + this.completedAt = completedAt; + } + + /** Records a terminal failure. */ + public void markFailed(Instant completedAt) { + requireInProgress(); + this.state = GrpcOperationLedgerState.FAILED_TERMINAL; + this.completedAt = completedAt; + } + + /** The port-facing view of this row. */ + public GrpcOperationLedgerRecord toRecord(GrpcOperationIdentity identity) { + return new GrpcOperationLedgerRecord( + identity, + state, + requestFingerprint, + Optional.ofNullable(outcomeReference), + claimedAt, + Optional.ofNullable(completedAt)); + } + + private void requireInProgress() { + if (state != GrpcOperationLedgerState.IN_PROGRESS) { + throw new IllegalStateException( + "operation '" + storageKey + "' is already " + state + " and cannot transition again"); + } + } +} diff --git a/src/grpc/grpc-operation-ledger-jpa/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationLedgerRepository.java b/src/grpc/grpc-operation-ledger-jpa/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationLedgerRepository.java new file mode 100644 index 00000000..81aa5aec --- /dev/null +++ b/src/grpc/grpc-operation-ledger-jpa/src/main/java/dev/caskeleton/grpc/ledger/GrpcOperationLedgerRepository.java @@ -0,0 +1,28 @@ +package dev.caskeleton.grpc.ledger; + +import java.util.Optional; +import org.springframework.data.repository.Repository; + +/** + * The narrow persistence interface the ledger adapter needs. + * + *

Extends {@code Repository} rather than {@code JpaRepository}, and the difference is the point: + * {@code JpaRepository} publishes {@code deleteAll}, {@code findAll} and {@code saveAll} on the + * table that decides whether a payment runs twice. Naming the four methods that are actually used + * also makes a hand-rolled test double a few lines rather than thirty stubs. + */ +public interface GrpcOperationLedgerRepository + extends Repository { + + /** Inserts or updates a claim. */ + GrpcOperationLedgerEntity save(GrpcOperationLedgerEntity entity); + + /** Reads a claim by its derived storage key. */ + Optional findById(String storageKey); + + /** Removes a claim. Only for one whose owner is known to be gone. */ + void deleteById(String storageKey); + + /** Whether a claim exists. */ + boolean existsById(String storageKey); +} diff --git a/src/grpc/grpc-operation-ledger-jpa/src/main/java/dev/caskeleton/grpc/ledger/JpaGrpcOperationLedger.java b/src/grpc/grpc-operation-ledger-jpa/src/main/java/dev/caskeleton/grpc/ledger/JpaGrpcOperationLedger.java new file mode 100644 index 00000000..3c374528 --- /dev/null +++ b/src/grpc/grpc-operation-ledger-jpa/src/main/java/dev/caskeleton/grpc/ledger/JpaGrpcOperationLedger.java @@ -0,0 +1,93 @@ +package dev.caskeleton.grpc.ledger; + +import java.time.Instant; +import java.util.Optional; +import org.springframework.dao.DataIntegrityViolationException; + +/** + * The JPA-backed operation ledger. + * + *

{@link #claim} is insert-first, read-on-conflict — not read-then-insert. That ordering is the + * whole adapter. Two concurrent attempts with the same idempotency key both try to insert; the + * database's unique constraint lets exactly one through and the other catches the violation and + * reads the winner's row. A read-first implementation has a window between the read and the insert + * that is exactly as wide as the race it is supposed to close, and it passes every test that does + * not run the two attempts concurrently. + * + *

No transaction annotations here. The commit is meant to happen inside the caller's business + * transaction, and a {@code REQUIRES_NEW} on this adapter would put the ledger in its own + * transaction — reintroducing the window in which the mutation is durable and the claim is not. + */ +public final class JpaGrpcOperationLedger implements GrpcOperationLedger { + + private final GrpcOperationLedgerRepository repository; + + /** Binds the adapter to its repository. */ + public JpaGrpcOperationLedger(GrpcOperationLedgerRepository repository) { + if (repository == null) { + throw new IllegalArgumentException("a ledger adapter needs a repository"); + } + this.repository = repository; + } + + @Override + public Optional claim( + GrpcOperationIdentity identity, String requestFingerprint, Instant now) { + requireIdentity(identity); + try { + repository.save(GrpcOperationLedgerEntity.claim(identity, requestFingerprint, now)); + return Optional.empty(); + } catch (DataIntegrityViolationException alreadyClaimed) { + // The unique constraint fired, which means somebody else won the race. Their row is the + // answer; the exception is how we find out, not a failure. + return repository.findById(identity.storageKey()).map(entity -> entity.toRecord(identity)); + } + } + + @Override + public Optional find(GrpcOperationIdentity identity) { + requireIdentity(identity); + return repository.findById(identity.storageKey()).map(entity -> entity.toRecord(identity)); + } + + @Override + public void markCommitted(GrpcOperationIdentity identity, String outcomeReference, Instant now) { + requireIdentity(identity); + GrpcOperationLedgerEntity entity = + repository + .findById(identity.storageKey()) + .orElseThrow( + () -> + new IllegalStateException( + "cannot commit operation '" + + identity.storageKey() + + "': no claim exists. The claim is what a retry reads; committing " + + "without one leaves the mutation durable and unguarded.")); + entity.markCommitted(outcomeReference, now); + repository.save(entity); + } + + @Override + public void markFailed(GrpcOperationIdentity identity, Instant now) { + requireIdentity(identity); + repository + .findById(identity.storageKey()) + .ifPresent( + entity -> { + entity.markFailed(now); + repository.save(entity); + }); + } + + @Override + public void release(GrpcOperationIdentity identity) { + requireIdentity(identity); + repository.deleteById(identity.storageKey()); + } + + private static void requireIdentity(GrpcOperationIdentity identity) { + if (identity == null) { + throw new IllegalArgumentException("an operation identity is required"); + } + } +} diff --git a/src/grpc/grpc-operation-ledger-jpa/src/main/resources/db/migration/grpc/V001__create_grpc_operation_ledger.sql b/src/grpc/grpc-operation-ledger-jpa/src/main/resources/db/migration/grpc/V001__create_grpc_operation_ledger.sql new file mode 100644 index 00000000..93f41af9 --- /dev/null +++ b/src/grpc/grpc-operation-ledger-jpa/src/main/resources/db/migration/grpc/V001__create_grpc_operation_ledger.sql @@ -0,0 +1,39 @@ +-- The gRPC operation ledger. +-- +-- Its own Flyway location (db/migration/grpc) rather than the shared one, mirroring how the JPA +-- leaf separates its streams: this table ships with a build-only platform leaf, and a deployment +-- that has not adopted the gRPC platform must not be forced to create it. +-- +-- The unique constraint is the contract. Two concurrent attempts with the same idempotency key both +-- reach this table, and exactly one insert wins; every other part of the idempotency design assumes +-- that. A uniqueness check in application code instead would be a read followed by a write, with a +-- window between them precisely as wide as the race it is meant to close. +CREATE TABLE grpc_operation_ledger ( + storage_key VARCHAR(512) NOT NULL, + caller_fingerprint VARCHAR(256) NOT NULL, + full_method_name VARCHAR(256) NOT NULL, + idempotency_key_hash VARCHAR(80) NOT NULL, + request_fingerprint VARCHAR(80) NOT NULL, + state VARCHAR(32) NOT NULL, + outcome_reference VARCHAR(512), + claimed_at TIMESTAMP WITH TIME ZONE NOT NULL, + completed_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT pk_grpc_operation_ledger PRIMARY KEY (storage_key), + CONSTRAINT uq_grpc_operation_ledger_identity + UNIQUE (caller_fingerprint, full_method_name, idempotency_key_hash), + CONSTRAINT ck_grpc_operation_ledger_state + CHECK (state IN ('IN_PROGRESS', 'COMMITTED', 'FAILED_TERMINAL')), + -- A committed row without an outcome cannot be replayed, which is the only reason to record a + -- commit at all. Enforced here as well as in the Java record, because a row written by a + -- migration, a backfill or a support script never passes through the record. + CONSTRAINT ck_grpc_operation_ledger_committed_has_outcome + CHECK (state <> 'COMMITTED' OR outcome_reference IS NOT NULL), + CONSTRAINT ck_grpc_operation_ledger_terminal_has_completion + CHECK (state = 'IN_PROGRESS' OR completed_at IS NOT NULL) +); + +-- Reclamation reads by state and age: an IN_PROGRESS row whose owner died has to be found before it +-- can be released, and without this index that scan is a full table scan on the hottest table in the +-- idempotency path. +CREATE INDEX ix_grpc_operation_ledger_state_claimed_at + ON grpc_operation_ledger (state, claimed_at); diff --git a/src/grpc/grpc-operation-ledger-jpa/src/test/java/dev/caskeleton/grpc/ledger/GrpcOperationLedgerRepositoryTest.java b/src/grpc/grpc-operation-ledger-jpa/src/test/java/dev/caskeleton/grpc/ledger/GrpcOperationLedgerRepositoryTest.java new file mode 100644 index 00000000..6ef6ea3a --- /dev/null +++ b/src/grpc/grpc-operation-ledger-jpa/src/test/java/dev/caskeleton/grpc/ledger/GrpcOperationLedgerRepositoryTest.java @@ -0,0 +1,208 @@ +package dev.caskeleton.grpc.ledger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +class GrpcOperationLedgerRepositoryTest { + + private static final GrpcMethodName CREATE = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument"); + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + private static final String FINGERPRINT = + "sha256:0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0"; + private static final String OTHER_FINGERPRINT = + "sha256:1122334455667788990011223344556677889900112233445566778899001122"; + private static final String KEY_HASH = + "sha256:aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; + + /** + * An in-memory stand-in for the Spring Data repository, with the unique constraint the real table + * carries. {@code putIfAbsent} is what makes the claim atomic here for the same reason the + * constraint does there; a fake without it would let the adapter's insert-first ordering pass + * while the property it protects does not hold. + */ + private static final class InMemoryRepository implements GrpcOperationLedgerRepository { + private final ConcurrentMap rows = new ConcurrentHashMap<>(); + + @Override + public GrpcOperationLedgerEntity save(GrpcOperationLedgerEntity entity) { + GrpcOperationLedgerEntity existing = rows.putIfAbsent(entity.getStorageKey(), entity); + if (existing != null && existing != entity) { + throw new DataIntegrityViolationException( + "uq_grpc_operation_ledger_identity violated for " + entity.getStorageKey()); + } + rows.put(entity.getStorageKey(), entity); + return entity; + } + + @Override + public Optional findById(String storageKey) { + return Optional.ofNullable(rows.get(storageKey)); + } + + @Override + public void deleteById(String storageKey) { + rows.remove(storageKey); + } + + @Override + public boolean existsById(String storageKey) { + return rows.containsKey(storageKey); + } + } + + private final InMemoryRepository repository = new InMemoryRepository(); + private final GrpcOperationLedger ledger = new JpaGrpcOperationLedger(repository); + + private static GrpcOperationIdentity identity(String callerFingerprint) { + return new GrpcOperationIdentity(callerFingerprint, CREATE, KEY_HASH); + } + + @Test + @DisplayName("the identity is caller, method and hashed key together") + void identityIsAllThreeParts() { + GrpcOperationIdentity id = identity("tenant-1.actor-1"); + + assertThat(id.storageKey()) + .contains("tenant-1.actor-1") + .contains(CREATE.canonical()) + .contains(KEY_HASH); + assertThatThrownBy(() -> new GrpcOperationIdentity("", CREATE, KEY_HASH)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("suppresses another tenant's write"); + assertThatThrownBy(() -> new GrpcOperationIdentity("caller", CREATE, "plain-key")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("stored hashed"); + } + + @Test + @DisplayName("the first claim wins and the second reads the winner's row") + void theFirstClaimWinsAndTheSecondReadsIt() { + GrpcOperationIdentity id = identity("tenant-1.actor-1"); + + assertThat(ledger.claim(id, FINGERPRINT, NOW)).isEmpty(); + Optional second = ledger.claim(id, FINGERPRINT, NOW); + + assertThat(second).isPresent(); + assertThat(second.get().state()).isEqualTo(GrpcOperationLedgerState.IN_PROGRESS); + assertThat(second.get().sameRequestAs(FINGERPRINT)).isTrue(); + } + + @Test + @DisplayName("one caller's key does not collide with another's") + void callersDoNotCollide() { + assertThat(ledger.claim(identity("tenant-1.actor-1"), FINGERPRINT, NOW)).isEmpty(); + assertThat(ledger.claim(identity("tenant-2.actor-9"), FINGERPRINT, NOW)).isEmpty(); + } + + @Test + @DisplayName("a committed claim carries the outcome a retry replays") + void aCommittedClaimCarriesItsOutcome() { + GrpcOperationIdentity id = identity("tenant-1.actor-1"); + ledger.claim(id, FINGERPRINT, NOW); + + ledger.markCommitted(id, "outcome://document/42", NOW.plusSeconds(1)); + + GrpcOperationLedgerRecord record = ledger.find(id).orElseThrow(); + assertThat(record.state()).isEqualTo(GrpcOperationLedgerState.COMMITTED); + assertThat(record.outcomeReference()).contains("outcome://document/42"); + assertThat(record.completedAt()).contains(NOW.plusSeconds(1)); + } + + @Test + @DisplayName("a commit without a claim is refused rather than silently inserted") + void committingWithoutAClaimIsRefused() { + assertThatThrownBy( + () -> ledger.markCommitted(identity("tenant-1.actor-1"), "outcome://document/42", NOW)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("durable and unguarded"); + } + + @Test + @DisplayName("a committed row cannot be committed again with a different outcome") + void aTerminalRowDoesNotTransitionAgain() { + GrpcOperationIdentity id = identity("tenant-1.actor-1"); + ledger.claim(id, FINGERPRINT, NOW); + ledger.markCommitted(id, "outcome://document/42", NOW.plusSeconds(1)); + + assertThatThrownBy(() -> ledger.markCommitted(id, "outcome://document/99", NOW.plusSeconds(2))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cannot transition again"); + } + + @Test + @DisplayName("a mismatched fingerprint is visible on the stored claim") + void aMismatchedFingerprintIsVisible() { + GrpcOperationIdentity id = identity("tenant-1.actor-1"); + ledger.claim(id, FINGERPRINT, NOW); + + GrpcOperationLedgerRecord stored = ledger.find(id).orElseThrow(); + + assertThat(stored.sameRequestAs(OTHER_FINGERPRINT)).isFalse(); + } + + @Test + @DisplayName("a failed claim is terminal, and releasing removes it") + void failureIsTerminalAndReleaseRemoves() { + GrpcOperationIdentity id = identity("tenant-1.actor-1"); + ledger.claim(id, FINGERPRINT, NOW); + + ledger.markFailed(id, NOW.plusSeconds(1)); + assertThat(ledger.find(id).orElseThrow().state()) + .isEqualTo(GrpcOperationLedgerState.FAILED_TERMINAL); + + ledger.release(id); + assertThat(ledger.find(id)).isEmpty(); + } + + @Test + @DisplayName("a committed record with no outcome cannot be constructed") + void aCommittedRecordNeedsAnOutcome() { + assertThatThrownBy( + () -> + new GrpcOperationLedgerRecord( + identity("tenant-1.actor-1"), + GrpcOperationLedgerState.COMMITTED, + FINGERPRINT, + Optional.empty(), + NOW, + Optional.of(NOW))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be replayed"); + } + + @Test + @DisplayName("the migration carries the unique constraint the atomic claim rests on") + void theMigrationCarriesTheUniqueConstraint() { + Path migration = + Path.of("src/main/resources/db/migration/grpc/V001__create_grpc_operation_ledger.sql"); + String sql = read(migration); + + assertThat(sql) + .contains("UNIQUE (caller_fingerprint, full_method_name, idempotency_key_hash)") + .contains("CHECK (state IN ('IN_PROGRESS', 'COMMITTED', 'FAILED_TERMINAL'))") + .contains("state <> 'COMMITTED' OR outcome_reference IS NOT NULL"); + } + + private static String read(Path path) { + try { + return Files.readString(path, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/grpc/grpc-policy/build.gradle b/src/grpc/grpc-policy/build.gradle new file mode 100644 index 00000000..5a3154f6 --- /dev/null +++ b/src/grpc/grpc-policy/build.gradle @@ -0,0 +1,23 @@ +apply plugin: 'java-library' + +// Validation, context propagation, status/rich-error mapping, TLS/credential profiles, deadline +// and cancellation, retry ownership and eligibility, idempotency and completion recovery, server +// streaming, payload size and compression. +// +// io.grpc versions are NOT managed by the Spring Boot BOM and this repo has no version catalog, so +// the grpc-bom is imported at MODULE scope from the root `ext.grpcVersion` SSOT — the same shape +// `adapter:inbound:grpc` uses, keeping the strict-locking blast radius local. +dependencyManagement { + imports { + mavenBom "io.grpc:grpc-bom:${grpcVersion}" + } +} + +dependencies { + api project(':grpc:grpc-core-api') + + // api: interceptors, context binders and the error mapper name io.grpc types in public + // signatures, so an adopter cannot compile against this module without them. + api "io.grpc:grpc-api:${grpcVersion}" + api "io.grpc:grpc-stub:${grpcVersion}" +} diff --git a/src/grpc/grpc-policy/gradle.lockfile b/src/grpc/grpc-policy/gradle.lockfile new file mode 100644 index 00000000..9369e831 --- /dev/null +++ b/src/grpc/grpc-policy/gradle.lockfile @@ -0,0 +1,91 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty= diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/context/GrpcContextBinder.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/context/GrpcContextBinder.java new file mode 100644 index 00000000..5f0f67cc --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/context/GrpcContextBinder.java @@ -0,0 +1,122 @@ +package dev.caskeleton.grpc.context; + +import io.grpc.Context; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; + +/** + * Carries a {@link GrpcContextSnapshot} across thread boundaries, and refuses to let work run + * without one. + * + *

Built on {@code io.grpc.Context} rather than a {@code ThreadLocal} of its own, so that a + * cancellation propagated by the framework and one propagated by this platform are the same + * cancellation. Two independent context mechanisms is how a call ends up cancelled in one of them. + * + *

Every wrapper attaches, runs and detaches in a finally block. Virtual threads make that more + * important rather than less: a leaked context on a platform thread is bounded by the pool size, + * while a leaked context on a virtual thread is bounded by nothing. + */ +public final class GrpcContextBinder { + + private static final Context.Key SNAPSHOT = Context.key("ca-grpc-snapshot"); + + private final GrpcContextPropagationPolicy policy; + + /** Binds to a propagation policy. */ + public GrpcContextBinder(GrpcContextPropagationPolicy policy) { + if (policy == null) { + throw new IllegalArgumentException("a context binder needs a propagation policy"); + } + this.policy = policy; + } + + /** The snapshot bound to the calling thread, if any. */ + public Optional current() { + return Optional.ofNullable(SNAPSHOT.get()); + } + + /** + * The snapshot bound to the calling thread. + * + * @throws IllegalStateException when the policy fails closed and no context is bound + */ + public GrpcContextSnapshot requireCurrent() { + GrpcContextSnapshot snapshot = SNAPSHOT.get(); + if (snapshot == null && policy.failClosedWithoutContext()) { + throw new IllegalStateException( + "no gRPC call context is bound to this thread; work with no actor, tenant or deadline " + + "produces a change attributed to nobody"); + } + if (snapshot == null) { + throw new IllegalStateException("no gRPC call context is bound to this thread"); + } + return snapshot; + } + + /** Runs {@code work} with {@code snapshot} bound, and unbinds afterwards. */ + public void runWith(GrpcContextSnapshot snapshot, Runnable work) { + requireArguments(snapshot, work); + Context context = Context.current().withValue(SNAPSHOT, snapshot); + Context previous = context.attach(); + try { + work.run(); + } finally { + context.detach(previous); + } + } + + /** Calls {@code work} with {@code snapshot} bound, and unbinds afterwards. */ + public T callWith(GrpcContextSnapshot snapshot, Callable work) throws Exception { + requireArguments(snapshot, work); + Context context = Context.current().withValue(SNAPSHOT, snapshot); + Context previous = context.attach(); + try { + return work.call(); + } finally { + context.detach(previous); + } + } + + /** + * A {@link Runnable} that restores the calling thread's context when it later runs elsewhere. + * + * @throws IllegalStateException when the policy fails closed and there is nothing to capture + */ + public Runnable wrap(Runnable work) { + if (work == null) { + throw new IllegalArgumentException("work must not be null"); + } + GrpcContextSnapshot captured = SNAPSHOT.get(); + if (captured == null && policy.failClosedWithoutContext()) { + throw new IllegalStateException( + "refusing to hand work to another thread with no call context to carry"); + } + if (captured == null) { + return work; + } + return () -> runWith(captured, work); + } + + /** An {@link Executor} that carries the submitting thread's context to every task. */ + public Executor wrap(Executor delegate) { + if (delegate == null) { + throw new IllegalArgumentException("an executor is required"); + } + return task -> delegate.execute(wrap(task)); + } + + /** The policy in force. */ + public GrpcContextPropagationPolicy policy() { + return policy; + } + + private static void requireArguments(GrpcContextSnapshot snapshot, Object work) { + if (snapshot == null) { + throw new IllegalArgumentException("a snapshot is required"); + } + if (work == null) { + throw new IllegalArgumentException("work must not be null"); + } + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/context/GrpcContextPropagationPolicy.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/context/GrpcContextPropagationPolicy.java new file mode 100644 index 00000000..c2c13073 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/context/GrpcContextPropagationPolicy.java @@ -0,0 +1,37 @@ +package dev.caskeleton.grpc.context; + +/** + * What happens when work runs without a call context. + * + *

Fail-closed is the default and the reason is that the alternative is invisible. Work that + * silently proceeds with no actor, no tenant and no deadline does not throw, does not log anything + * unusual, and produces a row attributed to nobody; the failure is discovered when somebody asks + * who changed a record and the answer is blank. + */ +public record GrpcContextPropagationPolicy( + boolean failClosedWithoutContext, boolean clearAfterTask) { + + /** The Stable policy: refuse contextless work, and always clear afterwards. */ + public static GrpcContextPropagationPolicy stable() { + return new GrpcContextPropagationPolicy(true, true); + } + + /** + * A policy for infrastructure work that legitimately has no caller, such as a scheduled drain. + * + *

Still clears after the task. A pooled thread that keeps the last request's identity is how + * one caller's tenant ends up on another's work. + */ + public static GrpcContextPropagationPolicy backgroundWork() { + return new GrpcContextPropagationPolicy(false, true); + } + + /** Refuses a policy that leaks context between pooled tasks. */ + public GrpcContextPropagationPolicy { + if (!clearAfterTask) { + throw new IllegalArgumentException( + "context must be cleared after every task; a pooled thread that keeps the previous " + + "caller's identity attributes one tenant's work to another"); + } + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/context/GrpcContextSnapshot.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/context/GrpcContextSnapshot.java new file mode 100644 index 00000000..e9e35b6a --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/context/GrpcContextSnapshot.java @@ -0,0 +1,70 @@ +package dev.caskeleton.grpc.context; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.deadline.GrpcCancellationToken; +import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget; +import java.time.Instant; +import java.util.Optional; + +/** + * The part of a call's context that may cross a thread boundary. + * + *

It carries the identity, the method, the deadline and when the caller's credential stops being + * valid; it does not carry the credential. A worker thread needs to know whose work it is doing and + * how long it has, and it needs to be able to stop when the authority expires — none of which + * requires the token itself. Copying the token into every executor task is how a credential ends up + * in a heap dump. + */ +public record GrpcContextSnapshot( + GrpcMethodName method, + GrpcClientIdentity identity, + GrpcDeadlineBudget deadline, + GrpcCancellationToken cancellation, + Optional credentialExpiry, + Optional traceId) { + + /** Requires everything except the two optional halves. */ + public GrpcContextSnapshot { + if (method == null + || identity == null + || deadline == null + || cancellation == null + || credentialExpiry == null + || traceId == null) { + throw new IllegalArgumentException("a context snapshot needs every field, Optional included"); + } + } + + /** Takes a snapshot of an inbound request context. */ + public static GrpcContextSnapshot of(GrpcRequestContext request, Instant credentialExpiry) { + if (request == null) { + throw new IllegalArgumentException("a snapshot needs a request context"); + } + return new GrpcContextSnapshot( + request.method(), + request.identity(), + request.deadline(), + request.cancellation(), + Optional.ofNullable(credentialExpiry), + request.traceId()); + } + + /** + * Whether the caller's authority is still valid at {@code now}. + * + *

Asked per unit of work rather than once at the start, because a long stream outlives the + * token that opened it and continuing to serve one after its credential expired is the same thing + * as never having checked. + */ + public boolean credentialValidAt(Instant now) { + if (now == null) { + throw new IllegalArgumentException("a validity check needs a moment"); + } + return credentialExpiry.map(expiry -> now.isBefore(expiry)).orElse(true); + } + + /** Whether work under this snapshot is still worth doing. */ + public boolean live(Instant now) { + return !cancellation.cancelled() && !deadline.expired() && credentialValidAt(now); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcCancellableOperation.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcCancellableOperation.java new file mode 100644 index 00000000..c2196e13 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcCancellableOperation.java @@ -0,0 +1,23 @@ +package dev.caskeleton.grpc.deadline; + +/** + * Something a cancellation has to reach. + * + *

Registered rather than discovered. Cancellation propagating "automatically" means whatever the + * framework happens to interrupt; a database statement, an HTTP exchange and a stream writer each + * need telling, and the ones that are not registered are the ones that keep running after the + * client has gone. + */ +public interface GrpcCancellableOperation { + + /** What this operation is, for the cancellation report. Never carries a key or a payload. */ + String name(); + + /** + * Stops the operation. + * + *

Must be safe to call more than once and must not throw: the coordinator cancels every + * registered operation, and one that throws would strand the rest. + */ + void cancel(GrpcCancellationReason reason); +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcCancellationCoordinator.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcCancellationCoordinator.java new file mode 100644 index 00000000..c2cf6cc4 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcCancellationCoordinator.java @@ -0,0 +1,122 @@ +package dev.caskeleton.grpc.deadline; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Propagates one cancellation to everything a call started, and refuses to let it start anything + * more. + * + *

Two properties do the work. The first is that registration fails after cancellation, which is + * how "no new external side effect after cancel" becomes something the code cannot get wrong rather + * than something every call site has to remember to check. + * + *

The second is {@link #markCommitBoundaryCrossed()}. A cancellation that arrives after the + * business transaction committed does not un-commit it, and treating cancellation as an abort is + * how a client is told its write failed while the row is in the database. After the boundary is + * marked, cancellation still stops further work and still cancels the response, but {@link + * #businessEffectAborted()} answers false and the completion outcome stays the transaction's. + */ +public final class GrpcCancellationCoordinator { + + private final GrpcCancellationToken token; + private final Map operations = new LinkedHashMap<>(); + private boolean commitBoundaryCrossed; + private GrpcCancellationReason reason; + + /** Coordinates cancellation for one call, sharing its token. */ + public GrpcCancellationCoordinator(GrpcCancellationToken token) { + if (token == null) { + throw new IllegalArgumentException("a cancellation coordinator needs a token"); + } + this.token = token; + } + + /** + * Registers something that must be stopped when the call is cancelled. + * + * @throws IllegalStateException when the call is already cancelled — the registration itself is + * the moment a new side effect would start + */ + public synchronized void register(GrpcCancellableOperation operation) { + if (operation == null) { + throw new IllegalArgumentException("an operation is required"); + } + if (token.cancelled()) { + throw new IllegalStateException( + "refusing to start '" + + operation.name() + + "': the call was already cancelled (" + + (reason == null ? "unknown" : reason.canonical()) + + ")"); + } + operations.put(operation.name(), operation); + } + + /** + * Cancels every registered operation once. + * + *

The first reason is kept. A call cancelled by its client and then, a millisecond later, by a + * shutdown drain has one cause worth reporting. + * + * @return the names of the operations this call cancelled; empty when it had already been + * cancelled + */ + public synchronized List cancel(GrpcCancellationReason cancellationReason, Instant at) { + if (cancellationReason == null || at == null) { + throw new IllegalArgumentException("cancellation needs a reason and a moment"); + } + if (!token.cancel(cancellationReason.canonical(), at)) { + return List.of(); + } + this.reason = cancellationReason; + List cancelled = new ArrayList<>(); + for (GrpcCancellableOperation operation : operations.values()) { + operation.cancel(cancellationReason); + cancelled.add(operation.name()); + } + return List.copyOf(cancelled); + } + + /** + * Records that the business transaction committed. + * + *

Called by the use case at its commit boundary, not by the transport. + */ + public synchronized void markCommitBoundaryCrossed() { + commitBoundaryCrossed = true; + } + + /** + * Whether the business effect should be treated as aborted. + * + *

False after the commit boundary, whatever the transport did. The client may still receive a + * cancellation; what it must not receive is a claim that nothing was written. + */ + public synchronized boolean businessEffectAborted() { + return token.cancelled() && !commitBoundaryCrossed; + } + + /** Whether the call has been cancelled. */ + public boolean cancelled() { + return token.cancelled(); + } + + /** The first cancellation reason, if the call was cancelled. */ + public synchronized Optional reason() { + return Optional.ofNullable(reason); + } + + /** + * Fails when a new external side effect would start after cancellation. + * + *

The same guard {@link #register} applies, exposed for work that has nothing to register. + */ + public void requireMayStartSideEffect(String operationName) { + token.requireNotCancelled(operationName); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcCancellationReason.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcCancellationReason.java new file mode 100644 index 00000000..92100ff0 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcCancellationReason.java @@ -0,0 +1,48 @@ +package dev.caskeleton.grpc.deadline; + +/** + * Why a call was cancelled. + * + *

A closed set rather than a free string, because the reason is what a drain report, a metric + * and an incident timeline all key on, and a free string produces four spellings of "client went + * away". + * + *

{@link #DEADLINE_EXCEEDED} is separated from {@link #CLIENT_CANCELLED} because they mean + * opposite things about the client: one gave up deliberately and will not be back, the other is + * probably retrying right now. + */ +public enum GrpcCancellationReason { + /** The client cancelled the call. */ + CLIENT_CANCELLED("client-cancelled"), + /** The deadline elapsed. */ + DEADLINE_EXCEEDED("deadline-exceeded"), + /** The server is shutting down and asked its calls to finish. */ + SERVER_DRAIN("server-drain"), + /** The caller's credential expired or was revoked mid-call. */ + CREDENTIAL_EXPIRED("credential-expired"), + /** A stream consumer fell too far behind its bounded queue. */ + SLOW_CONSUMER("slow-consumer"), + /** A dependency this call needed failed terminally. */ + UPSTREAM_FAILURE("upstream-failure"); + + private final String canonical; + + GrpcCancellationReason(String canonical) { + this.canonical = canonical; + } + + /** The stable, log-safe spelling. */ + public String canonical() { + return canonical; + } + + /** + * Whether the client is likely to be attempting the same work again. + * + *

Used to decide whether releasing an idempotency claim early is safe: it is not, when the + * caller is retrying. + */ + public boolean clientLikelyRetrying() { + return this == DEADLINE_EXCEEDED || this == UPSTREAM_FAILURE; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcDeadlineCalculator.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcDeadlineCalculator.java new file mode 100644 index 00000000..a9ddfdd4 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcDeadlineCalculator.java @@ -0,0 +1,68 @@ +package dev.caskeleton.grpc.deadline; + +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import java.time.Duration; + +/** + * Works out how much time a call and its dependencies actually get. + * + *

One implementation of "shorter of the two, minus the reserve", used by the server when a call + * arrives and by the client before every dependency call. The rule is simple enough that every call + * site is tempted to inline it, and the versions that get inlined are the ones that forget the + * reserve. + */ +public final class GrpcDeadlineCalculator { + + private GrpcDeadlineCalculator() {} + + /** + * The budget for an inbound call. + * + * @param parentRemaining what the caller's deadline leaves, or null when none was propagated + * @throws IllegalArgumentException when a Stable unary method arrives with no deadline + */ + public static GrpcDeadlineBudget forInboundCall( + GrpcMethodPolicy policy, Duration parentRemaining) { + if (policy == null) { + throw new IllegalArgumentException("an inbound budget needs a method policy"); + } + if (policy.rpcType() == RpcType.UNARY) { + return GrpcDeadlineBudget.forEntryPoint(parentRemaining, policy.deadline()); + } + // A stream's lifetime is governed by its own lifetime policy rather than by a single deadline, + // so an absent parent deadline is legitimate here and the method default applies. + Duration effective = parentRemaining == null ? policy.deadline().total() : parentRemaining; + return GrpcDeadlineBudget.forEntryPoint(effective, policy.deadline()); + } + + /** + * The budget for a call to {@code dependency}, given what the inbound call has left. + * + * @return a budget that is never longer than {@code inbound} and never longer than the + * dependency's own timeout + */ + public static GrpcDeadlineBudget forDependency( + GrpcDeadlineBudget inbound, GrpcDependencyBudget dependency) { + if (inbound == null || dependency == null) { + throw new IllegalArgumentException( + "a dependency budget needs the inbound budget and the dependency"); + } + GrpcDeadlineProfile profile = + new GrpcDeadlineProfile( + dependency.timeout(), + dependency.timeout().dividedBy(10), + dependency.minimumAttemptBudget()); + return inbound.deriveDownstream(profile); + } + + /** + * Whether a dependency call should be started at all. + * + *

Starting one that cannot finish produces a deadline failure on the dependency that is + * indistinguishable from the dependency being slow. + */ + public static boolean shouldStart(GrpcDeadlineBudget inbound, GrpcDependencyBudget dependency) { + return forDependency(inbound, dependency).canStartDependencyCall(); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcDeadlinePolicyValidator.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcDeadlinePolicyValidator.java new file mode 100644 index 00000000..41788929 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcDeadlinePolicyValidator.java @@ -0,0 +1,93 @@ +package dev.caskeleton.grpc.deadline; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import dev.caskeleton.grpc.policy.GrpcMethodPolicyCatalog; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * A startup check that every method's deadline is larger than every dependency it will wait on. + * + *

This is a configuration bug that has no runtime symptom of its own. The dependency timeout + * never fires, so nothing logs it; what shows up is the caller's deadline, attributed to whichever + * system happened to be slow at the time. Checking it at startup is the only point where both + * numbers are in the same place. + */ +public final class GrpcDeadlinePolicyValidator { + + private GrpcDeadlinePolicyValidator() {} + + /** + * Every method whose deadline is too short for a dependency it uses. + * + * @param dependenciesByMethod which dependencies each method calls + * @return an empty list when the configuration is coherent + */ + public static List validate( + GrpcMethodPolicyCatalog catalog, + Map> dependenciesByMethod) { + if (catalog == null || dependenciesByMethod == null) { + throw new IllegalArgumentException("validation needs a catalog and a dependency map"); + } + List violations = new ArrayList<>(); + dependenciesByMethod.forEach( + (method, dependencies) -> { + GrpcMethodPolicy policy = catalog.find(method).orElse(null); + if (policy == null) { + violations.add( + "method '" + + method.canonical() + + "' declares dependencies but has no policy, so it has no deadline to fit " + + "them inside"); + return; + } + java.time.Duration usable = policy.deadline().usable(); + dependencies.stream() + .sorted(java.util.Comparator.comparing(GrpcDependencyBudget::dependencyName)) + .filter(dependency -> !dependency.fitsWithin(usable)) + .forEach( + dependency -> + violations.add( + "method '" + + method.canonical() + + "' has " + + usable + + " usable but calls '" + + dependency.dependencyName() + + "' with a " + + dependency.timeout() + + " timeout; the dependency timeout can never fire")); + }); + return List.copyOf(violations); + } + + /** + * Every Stable unary method whose policy would allow a call with no deadline. + * + *

Separate from the check above because it is a different failure: not a timeout that cannot + * fire, but a call that never times out at all. + */ + public static List validateUnaryDeadlinesPresent(GrpcMethodPolicyCatalog catalog) { + if (catalog == null) { + throw new IllegalArgumentException("validation needs a catalog"); + } + List violations = new ArrayList<>(); + catalog.methods().stream() + .sorted(java.util.Comparator.comparing(GrpcMethodName::canonical)) + .forEach( + method -> { + GrpcMethodPolicy policy = catalog.require(method); + if (policy.rpcType() == RpcType.UNARY && policy.deadline().usable().isZero()) { + violations.add( + "Stable unary method '" + + method.canonical() + + "' has no usable deadline once its reserve is subtracted"); + } + }); + return List.copyOf(violations); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcDependencyBudget.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcDependencyBudget.java new file mode 100644 index 00000000..6e9b652b --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/deadline/GrpcDependencyBudget.java @@ -0,0 +1,48 @@ +package dev.caskeleton.grpc.deadline; + +import java.time.Duration; + +/** + * A downstream dependency's configured timeout, declared so it can be checked against the inbound + * deadlines of the methods that use it. + * + *

Declaring it is the point. A JDBC statement timeout, an HTTP client read timeout and a + * downstream gRPC deadline are each configured somewhere else entirely, and nothing compares them + * to the deadline of the call that will be waiting on them. A dependency timeout longer than its + * caller's deadline never fires: the caller gives up first, and the operator investigating a + * "timeout" is looking at the wrong system. + */ +public record GrpcDependencyBudget( + String dependencyName, Duration timeout, Duration minimumAttemptBudget) { + + /** Requires a name and a positive timeout. */ + public GrpcDependencyBudget { + if (dependencyName == null || dependencyName.isBlank()) { + throw new IllegalArgumentException("a dependency budget names the dependency"); + } + if (timeout == null || timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException( + "dependency '" + dependencyName + "' needs a positive timeout"); + } + if (minimumAttemptBudget == null || minimumAttemptBudget.isNegative()) { + throw new IllegalArgumentException( + "a minimum attempt budget must be present and non-negative"); + } + if (minimumAttemptBudget.compareTo(timeout) > 0) { + throw new IllegalArgumentException( + "dependency '" + + dependencyName + + "' cannot require more time for one attempt than its whole timeout"); + } + } + + /** A budget whose smallest useful attempt is a twentieth of its timeout. */ + public static GrpcDependencyBudget of(String dependencyName, Duration timeout) { + return new GrpcDependencyBudget(dependencyName, timeout, timeout.dividedBy(20)); + } + + /** Whether this dependency fits inside {@code inboundDeadline}. */ + public boolean fitsWithin(Duration inboundDeadline) { + return inboundDeadline != null && timeout.compareTo(inboundDeadline) <= 0; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/error/GrpcErrorExposurePolicy.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/error/GrpcErrorExposurePolicy.java new file mode 100644 index 00000000..7d9633c5 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/error/GrpcErrorExposurePolicy.java @@ -0,0 +1,82 @@ +package dev.caskeleton.grpc.error; + +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Decides whether a string is safe to send to a client. + * + *

An allowlist would be better and is not available: the safe strings are field paths and reason + * constants the platform already builds itself, and this policy exists for the values that arrive + * from somewhere else. So it is a denylist of the shapes that actually leak — a stack frame, a SQL + * fragment, a JDBC URL, a bearer token, a host and port, a file path — and the default answer for + * anything it recognises is to drop the string entirely rather than to redact part of it. A + * partially redacted driver message is still a driver message. + */ +public final class GrpcErrorExposurePolicy { + + private static final List FORBIDDEN = + List.of( + Pattern.compile("\\bat\\s+[\\w.$]+\\([\\w.]+:\\d+\\)"), + Pattern.compile("(?i)\\bsql\\s*state\\b"), + Pattern.compile( + "(?i)\\b(select|insert|update|delete|drop|alter)\\s+\\w+\\s+(from|into|set|table)\\b"), + Pattern.compile("(?i)\\bjdbc:[a-z0-9]+:"), + Pattern.compile("(?i)\\b(bearer|basic)\\s+[A-Za-z0-9._\\-+/=]{8,}"), + Pattern.compile( + "(?i)\\b(password|passwd|secret|api[_-]?key|private[_-]?key|token)\\b\\s*[:=]"), + Pattern.compile("\\b\\d{1,3}(\\.\\d{1,3}){3}(:\\d{1,5})?\\b"), + Pattern.compile("(?i)-----BEGIN [A-Z ]*PRIVATE KEY-----"), + Pattern.compile("(^|\\s)(/[\\w.\\-]+){2,}")); + + private static final int MAX_LENGTH = 256; + + private GrpcErrorExposurePolicy() {} + + /** Whether {@code candidate} may be sent to a client as-is. */ + public static boolean safeToExpose(String candidate) { + if (candidate == null || candidate.isBlank()) { + return false; + } + if (candidate.length() > MAX_LENGTH) { + return false; + } + for (Pattern forbidden : FORBIDDEN) { + if (forbidden.matcher(candidate).find()) { + return false; + } + } + return true; + } + + /** + * {@code candidate} when it is safe, {@code fallback} when it is not. + * + *

Whole-string replacement, never partial redaction: a message with one masked substring still + * carries the table name, the host and the query shape around it. + */ + public static String exposeOr(String candidate, String fallback) { + if (fallback == null || fallback.isBlank()) { + throw new IllegalArgumentException("a fallback description is required"); + } + return safeToExpose(candidate) ? candidate : fallback; + } + + /** A stable, uppercase reason constant derived from an exception type name. */ + public static String reasonFor(Class exceptionType) { + if (exceptionType == null) { + throw new IllegalArgumentException("an exception type is required"); + } + String simpleName = exceptionType.getSimpleName(); + StringBuilder reason = new StringBuilder(simpleName.length() + 8); + for (int index = 0; index < simpleName.length(); index++) { + char character = simpleName.charAt(index); + if (Character.isUpperCase(character) && index > 0) { + reason.append('_'); + } + reason.append(Character.toUpperCase(character)); + } + return reason.toString().toUpperCase(Locale.ROOT); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/error/GrpcErrorMapper.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/error/GrpcErrorMapper.java new file mode 100644 index 00000000..aaf8567c --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/error/GrpcErrorMapper.java @@ -0,0 +1,179 @@ +package dev.caskeleton.grpc.error; + +import dev.caskeleton.grpc.core.GrpcStatusCode; +import dev.caskeleton.grpc.validation.GrpcValidationViolation; +import io.grpc.Metadata; +import io.grpc.Status; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +/** + * Turns an internal failure into the wire contract: a stable status, a stable reason and typed + * details. + * + *

The rule it exists to hold is that a client never reads a message string. Everything a caller + * needs to branch on is a code, an {@code ErrorInfo.reason}, or a typed detail; the description is + * for a human reading a log and is replaced wholesale whenever it is not provably safe. + * + *

An unrecognised exception becomes {@code INTERNAL} with an opaque execution id and nothing + * else. The id is the entire bridge between what the client saw and what the operator can find, and + * it is generated rather than derived so that it cannot accidentally encode a key or a row id. + */ +public final class GrpcErrorMapper { + + /** Trailer carrying the stable reason constant. */ + public static final Metadata.Key REASON_KEY = + Metadata.Key.of("error-reason", Metadata.ASCII_STRING_MARSHALLER); + + /** Trailer carrying the opaque execution id an operator correlates on. */ + public static final Metadata.Key EXECUTION_ID_KEY = + Metadata.Key.of("error-execution-id", Metadata.ASCII_STRING_MARSHALLER); + + /** Trailer carrying the completion outcome for a state-changing call. */ + public static final Metadata.Key COMPLETION_OUTCOME_KEY = + Metadata.Key.of("completion-outcome", Metadata.ASCII_STRING_MARSHALLER); + + private static final String OPAQUE_DESCRIPTION = "the server failed to complete the request"; + + private final String domain; + private final Supplier executionIdSupplier; + + /** + * Binds a mapper to the domain it reports and the source of its opaque ids. + * + * @param executionIdSupplier injected rather than hard-wired to {@code UUID.randomUUID} so a test + * can assert the id reaches the trailers without asserting on a random value + */ + public GrpcErrorMapper(String domain, Supplier executionIdSupplier) { + if (domain == null || domain.isBlank()) { + throw new IllegalArgumentException("an error mapper names the domain it speaks for"); + } + if (executionIdSupplier == null) { + throw new IllegalArgumentException("an error mapper needs a source of execution ids"); + } + this.domain = domain; + this.executionIdSupplier = executionIdSupplier; + } + + /** What the client receives: a status, its trailers, and the details that went into them. */ + public record MappedError( + Status status, Metadata trailers, List details, String executionId) { + /** Requires the whole triple. */ + public MappedError { + if (status == null || trailers == null || details == null) { + throw new IllegalArgumentException("a mapped error carries a status, trailers and details"); + } + details = List.copyOf(details); + } + } + + /** Maps a transport validation failure. */ + public MappedError mapValidation(List violations) { + if (violations == null || violations.isEmpty()) { + throw new IllegalArgumentException("a validation failure names at least one violation"); + } + String executionId = executionIdSupplier.get(); + List details = + List.of( + new GrpcRichErrorDetail.BadRequest(violations), + new GrpcRichErrorDetail.ErrorInfo("VALIDATION_FAILED", domain, executionId)); + return finish( + Status.INVALID_ARGUMENT.withDescription("request failed transport validation"), + "VALIDATION_FAILED", + executionId, + null, + details); + } + + /** + * Maps a classified platform failure. + * + *

The retry hint is derived from the failure's own disposition rather than from the status, + * because the two disagree exactly where it matters: a {@code DEADLINE_EXCEEDED} mutation is a + * retryable-looking status whose disposition is "resolve the result first". + */ + public MappedError mapPlatformFailure(GrpcPlatformException failure) { + if (failure == null) { + throw new IllegalArgumentException("a platform failure is required"); + } + GrpcFailureContext context = failure.context(); + String executionId = executionIdSupplier.get(); + List details = new ArrayList<>(); + details.add( + new GrpcRichErrorDetail.ErrorInfo(reasonFor(context.category()), domain, executionId)); + details.add(retryInfoFor(context)); + if (context.category() == GrpcFailureCategory.PRECONDITION) { + details.add( + new GrpcRichErrorDetail.PreconditionFailure( + List.of( + new GrpcRichErrorDetail.PreconditionFailure.Violation( + "STATE", context.method().canonical())))); + } + + Status status = GrpcStatusMapping.toTransport(context.statusCode()); + String description = + context.category() == GrpcFailureCategory.INTERNAL + ? OPAQUE_DESCRIPTION + : GrpcErrorExposurePolicy.exposeOr(context.redactedSummary(), OPAQUE_DESCRIPTION); + return finish( + status.withDescription(description), + reasonFor(context.category()), + executionId, + context.completionOutcome().name(), + details); + } + + /** + * Maps anything the platform does not recognise. + * + *

{@code INTERNAL}, a generic description and an opaque id. Nothing derived from the exception + * reaches the client, including its type: an exception class name is a fingerprint of the stack + * behind it, and the stable reason constant is what a client is supposed to branch on anyway. + */ + public MappedError mapUnknown(Throwable failure) { + String executionId = executionIdSupplier.get(); + List details = + List.of(new GrpcRichErrorDetail.ErrorInfo("INTERNAL_ERROR", domain, executionId)); + return finish( + Status.INTERNAL.withDescription(OPAQUE_DESCRIPTION).withCause(failure), + "INTERNAL_ERROR", + executionId, + null, + details); + } + + private static GrpcRichErrorDetail retryInfoFor(GrpcFailureContext context) { + return switch (context.retryDisposition()) { + case RETRYABLE -> + new GrpcRichErrorDetail.RetryInfo(Duration.ofMillis(200), context.attempt()); + case NOT_RETRYABLE, TERMINAL, RESOLVE_FIRST -> + GrpcRichErrorDetail.RetryInfo.doNotRetry(context.attempt()); + }; + } + + private static String reasonFor(GrpcFailureCategory category) { + return category.name(); + } + + private static MappedError finish( + Status status, + String reason, + String executionId, + String completionOutcome, + List details) { + Metadata trailers = new Metadata(); + trailers.put(REASON_KEY, reason); + trailers.put(EXECUTION_ID_KEY, executionId); + if (completionOutcome != null) { + trailers.put(COMPLETION_OUTCOME_KEY, completionOutcome); + } + return new MappedError(status, trailers, details, executionId); + } + + /** The platform status code a mapped error carries, for callers that stay framework-free. */ + public static GrpcStatusCode statusCodeOf(MappedError mapped) { + return GrpcStatusMapping.fromTransport(mapped.status()); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/error/GrpcRichErrorDetail.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/error/GrpcRichErrorDetail.java new file mode 100644 index 00000000..2f11596b --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/error/GrpcRichErrorDetail.java @@ -0,0 +1,93 @@ +package dev.caskeleton.grpc.error; + +import dev.caskeleton.grpc.validation.GrpcValidationViolation; +import java.time.Duration; +import java.util.List; + +/** + * The closed set of structured details this platform will put on the wire. + * + *

Sealed rather than open, because "allowlisted detail" only means something if the list cannot + * grow at a call site. Each shape mirrors its {@code google.rpc} counterpart so a later move onto + * the common protos is a rename; owning them here is what keeps the surface reviewable while this + * repository generates no protobuf (adaptation D6). + */ +public sealed interface GrpcRichErrorDetail { + + /** Which field-level constraints the request broke. */ + record BadRequest(List fieldViolations) implements GrpcRichErrorDetail { + /** Requires at least one violation and copies the list. */ + public BadRequest { + if (fieldViolations == null || fieldViolations.isEmpty()) { + throw new IllegalArgumentException("a BadRequest detail names at least one violation"); + } + fieldViolations = List.copyOf(fieldViolations); + } + } + + /** + * Whether and when to try again. + * + *

The only channel for a retry hint. A hint carried in a message string is a hint a client + * parses with a regex, and the regex outlives the wording. + */ + record RetryInfo(Duration retryDelay, int attempt) implements GrpcRichErrorDetail { + /** Requires a non-negative delay and a one-based attempt. */ + public RetryInfo { + if (retryDelay == null || retryDelay.isNegative()) { + throw new IllegalArgumentException("retry delay must be present and non-negative"); + } + if (attempt < 1) { + throw new IllegalArgumentException("attempt is 1-based"); + } + } + + /** A hint that says explicitly not to retry. */ + public static RetryInfo doNotRetry(int attempt) { + return new RetryInfo(Duration.ZERO, attempt); + } + + /** Whether this hint permits another attempt. */ + public boolean retryable() { + return !retryDelay.isZero(); + } + } + + /** A stable reason a client may branch on, plus an opaque handle an operator can correlate. */ + record ErrorInfo(String reason, String domain, String executionId) + implements GrpcRichErrorDetail { + /** Requires a reason and a domain; the execution id may be absent. */ + public ErrorInfo { + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("an ErrorInfo detail carries a stable reason constant"); + } + if (domain == null || domain.isBlank()) { + throw new IllegalArgumentException("an ErrorInfo detail names the domain that produced it"); + } + if (executionId != null && executionId.isBlank()) { + throw new IllegalArgumentException("an execution id is either absent or a real handle"); + } + } + } + + /** The state that made the operation impossible. */ + record PreconditionFailure(List violations) implements GrpcRichErrorDetail { + /** One unmet precondition. */ + public record Violation(String type, String subject) { + /** Requires both halves. */ + public Violation { + if (type == null || type.isBlank() || subject == null || subject.isBlank()) { + throw new IllegalArgumentException("a precondition violation names a type and a subject"); + } + } + } + + /** Requires at least one violation and copies the list. */ + public PreconditionFailure { + if (violations == null || violations.isEmpty()) { + throw new IllegalArgumentException("a PreconditionFailure names at least one violation"); + } + violations = List.copyOf(violations); + } + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/error/GrpcStatusMapping.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/error/GrpcStatusMapping.java new file mode 100644 index 00000000..ba20db95 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/error/GrpcStatusMapping.java @@ -0,0 +1,53 @@ +package dev.caskeleton.grpc.error; + +import dev.caskeleton.grpc.core.GrpcStatusCode; +import io.grpc.Status; + +/** + * The one place the framework-free status enum and {@code io.grpc.Status} meet. + * + *

{@code grpc-core-api} mirrors the canonical codes so that evidence and failure contexts stay + * free of io.grpc. That mirror is only safe if the translation is total and tested in both + * directions, which is what this class is for; a mapping scattered across interceptors is a mapping + * with a different answer in each of them. + */ +public final class GrpcStatusMapping { + + private GrpcStatusMapping() {} + + /** The transport status for a platform status code. */ + public static Status toTransport(GrpcStatusCode code) { + if (code == null) { + throw new IllegalArgumentException("a status code is required"); + } + return Status.fromCodeValue(code.value()); + } + + /** + * The platform status code for a transport status. + * + *

An unrecognised code becomes {@link GrpcStatusCode#UNKNOWN} rather than throwing: a status + * arriving from a peer is data, and a mapper that throws on unexpected data turns a remote + * protocol difference into a local crash. + */ + public static GrpcStatusCode fromTransport(Status status) { + if (status == null) { + throw new IllegalArgumentException("a status is required"); + } + int value = status.getCode().value(); + for (GrpcStatusCode candidate : GrpcStatusCode.values()) { + if (candidate.value() == value) { + return candidate; + } + } + return GrpcStatusCode.UNKNOWN; + } + + /** The transport status a failure category maps to. */ + public static Status toTransport(GrpcFailureCategory category) { + if (category == null) { + throw new IllegalArgumentException("a failure category is required"); + } + return toTransport(category.statusCode()); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcCompletionReconciler.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcCompletionReconciler.java new file mode 100644 index 00000000..0e78a5b6 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcCompletionReconciler.java @@ -0,0 +1,93 @@ +package dev.caskeleton.grpc.idempotency; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +/** + * Turns an unknown completion into a decision, or hands it on. + * + *

Two sources, consulted in a fixed order: the ledger first, then — only when the ledger has + * nothing and the method offers one — a business-resource probe. The order matters because they + * answer different questions. The ledger knows whether this exact operation was claimed; the + * resource only knows whether something that looks like the result exists, which is a weaker + * statement and can be satisfied by a different caller's write. + * + *

When neither is conclusive, the resolution stays UNKNOWN and the case is queued. Guessing is + * the failure mode this class exists to make impossible: a reconciler that resolves an ambiguous + * case by re-issuing has converted an unknown into a duplicate. + */ +public final class GrpcCompletionReconciler { + + private final GrpcOperationStatusQuery statusQuery; + private final List pending = new ArrayList<>(); + + /** One operation whose result could not be determined. */ + public record PendingCase( + GrpcMethodName method, String callerFingerprint, String idempotencyKey, String reason) { + + /** Requires the identity parts and a reason. */ + public PendingCase { + if (method == null || callerFingerprint == null || idempotencyKey == null) { + throw new IllegalArgumentException("a pending case identifies its operation"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("a pending case says why it is pending"); + } + } + } + + /** Binds a reconciler to the status query it uses. */ + public GrpcCompletionReconciler(GrpcOperationStatusQuery statusQuery) { + if (statusQuery == null) { + throw new IllegalArgumentException("a reconciler needs a status query"); + } + this.statusQuery = statusQuery; + } + + /** + * Resolves an unknown completion. + * + * @param businessProbe an optional method-specific check of the business resource, consulted only + * when the ledger holds no claim. Null when the method has no such check, which is the common + * case and is why the ledger exists. + */ + public GrpcCompletionResolution reconcile( + GrpcMethodName method, + String callerFingerprint, + String idempotencyKey, + Function> businessProbe) { + GrpcCompletionResolution fromLedger = + statusQuery.resolve(method, callerFingerprint, idempotencyKey); + if (fromLedger.status() == GrpcOperationStatus.COMMITTED + || fromLedger.status() == GrpcOperationStatus.FAILED_TERMINAL) { + return fromLedger; + } + if (fromLedger.status() == GrpcOperationStatus.NOT_FOUND && businessProbe != null) { + Optional observed = businessProbe.apply(method); + if (observed.isPresent()) { + return GrpcCompletionResolution.of( + GrpcOperationStatus.COMMITTED, + observed, + "no ledger claim, but the business resource shows the effect"); + } + return fromLedger; + } + if (fromLedger.requiresReconciliation()) { + pending.add(new PendingCase(method, callerFingerprint, idempotencyKey, fromLedger.reason())); + } + return fromLedger; + } + + /** Cases that could not be resolved and are waiting for a later pass. */ + public List pendingCases() { + return List.copyOf(pending); + } + + /** Forgets a case that has since been resolved. */ + public void clearPending(PendingCase resolved) { + pending.remove(resolved); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcCompletionResolution.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcCompletionResolution.java new file mode 100644 index 00000000..aabafba4 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcCompletionResolution.java @@ -0,0 +1,52 @@ +package dev.caskeleton.grpc.idempotency; + +import java.util.Optional; + +/** + * The answer to "did that mutation actually happen". + * + *

Carries the outcome reference when the answer is COMMITTED, so the caller can return the + * original result rather than a fresh one. Returning a newly computed answer for an operation that + * committed earlier is a subtle correctness bug: the resource may have changed since, and the + * caller would be told the state at reconciliation time as if it were the state its own call + * produced. + */ +public record GrpcCompletionResolution( + GrpcOperationStatus status, Optional outcomeReference, String reason) { + + /** Requires a status, the Optional and a reason. */ + public GrpcCompletionResolution { + if (status == null || outcomeReference == null) { + throw new IllegalArgumentException("a resolution has a status and the outcome Optional"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("a resolution explains itself"); + } + if (status == GrpcOperationStatus.COMMITTED && outcomeReference.isEmpty()) { + throw new IllegalArgumentException( + "a COMMITTED resolution carries the outcome to return; without it the caller has to " + + "recompute an answer that may no longer match what its call produced"); + } + } + + /** A resolution with the given status. */ + public static GrpcCompletionResolution of( + GrpcOperationStatus status, Optional outcomeReference, String reason) { + return new GrpcCompletionResolution(status, outcomeReference, reason); + } + + /** The ledger could not be consulted. */ + public static GrpcCompletionResolution unknown(String reason) { + return new GrpcCompletionResolution(GrpcOperationStatus.UNKNOWN, Optional.empty(), reason); + } + + /** Whether the caller may re-issue the mutation on this answer. */ + public boolean safeToReissue() { + return status.safeToReissue(); + } + + /** Whether this must be handed to a reconciliation job rather than answered now. */ + public boolean requiresReconciliation() { + return status == GrpcOperationStatus.UNKNOWN || status == GrpcOperationStatus.IN_PROGRESS; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcIdempotencyDecision.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcIdempotencyDecision.java new file mode 100644 index 00000000..ecc9f400 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcIdempotencyDecision.java @@ -0,0 +1,95 @@ +package dev.caskeleton.grpc.idempotency; + +import java.time.Duration; +import java.util.Optional; + +/** + * What to do with a request that carries an idempotency key. + * + *

Five outcomes rather than a boolean, because the interesting ones are not "duplicate" and "not + * duplicate". A duplicate of a committed operation should be answered with the original result; a + * duplicate of a running one is a race that has to wait or be refused; and a key reused for a + * different request is a caller bug that must be reported rather than absorbed. + */ +public record GrpcIdempotencyDecision( + Outcome outcome, Optional outcomeReference, Duration retryAfter, String reason) { + + /** The five ways an idempotency check can end. */ + public enum Outcome { + /** No prior claim; this caller owns the operation and should proceed. */ + PROCEED, + /** A committed claim with the same fingerprint; return the stored outcome. */ + REPLAY, + /** A claim is in progress; wait and poll within the method's wait policy. */ + WAIT_AND_POLL, + /** A claim is in progress and this method refuses to wait. */ + REJECT_IN_PROGRESS, + /** The key was reused for a different request. A caller error, not a duplicate. */ + FINGERPRINT_MISMATCH + } + + /** Requires the parts each outcome needs. */ + public GrpcIdempotencyDecision { + if (outcome == null) { + throw new IllegalArgumentException("an idempotency decision has an outcome"); + } + if (outcomeReference == null) { + throw new IllegalArgumentException("the outcome reference Optional must be present"); + } + if (retryAfter == null || retryAfter.isNegative()) { + throw new IllegalArgumentException("retryAfter must be present and non-negative"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("an idempotency decision explains itself"); + } + if (outcome == Outcome.REPLAY && outcomeReference.isEmpty()) { + throw new IllegalArgumentException("a REPLAY decision needs the outcome it is replaying"); + } + if (outcome == Outcome.WAIT_AND_POLL && retryAfter.isZero()) { + throw new IllegalArgumentException("a WAIT_AND_POLL decision says how long to wait"); + } + } + + /** Proceed with the operation. */ + public static GrpcIdempotencyDecision proceed(String reason) { + return new GrpcIdempotencyDecision(Outcome.PROCEED, Optional.empty(), Duration.ZERO, reason); + } + + /** Replay a stored outcome. */ + public static GrpcIdempotencyDecision replay(String outcomeReference) { + return new GrpcIdempotencyDecision( + Outcome.REPLAY, + Optional.of(outcomeReference), + Duration.ZERO, + "an identical request already committed; returning the stored outcome"); + } + + /** Wait and poll for an in-progress claim. */ + public static GrpcIdempotencyDecision waitAndPoll(Duration retryAfter) { + return new GrpcIdempotencyDecision( + Outcome.WAIT_AND_POLL, Optional.empty(), retryAfter, "an identical request is in progress"); + } + + /** Refuse a duplicate of an in-progress claim. */ + public static GrpcIdempotencyDecision rejectInProgress() { + return new GrpcIdempotencyDecision( + Outcome.REJECT_IN_PROGRESS, + Optional.empty(), + Duration.ZERO, + "an identical request is in progress and this method does not wait for it"); + } + + /** Report a key reused for a different request. */ + public static GrpcIdempotencyDecision fingerprintMismatch() { + return new GrpcIdempotencyDecision( + Outcome.FINGERPRINT_MISMATCH, + Optional.empty(), + Duration.ZERO, + "the idempotency key was already used for a different request"); + } + + /** Whether the use case should run. */ + public boolean shouldProceed() { + return outcome == Outcome.PROCEED; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcIdempotencyInterceptor.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcIdempotencyInterceptor.java new file mode 100644 index 00000000..10ba5bc8 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcIdempotencyInterceptor.java @@ -0,0 +1,148 @@ +package dev.caskeleton.grpc.idempotency; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.ledger.GrpcOperationIdentity; +import dev.caskeleton.grpc.ledger.GrpcOperationLedger; +import dev.caskeleton.grpc.ledger.GrpcOperationLedgerRecord; +import dev.caskeleton.grpc.ledger.GrpcOperationLedgerState; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import dev.caskeleton.grpc.policy.GrpcMethodPolicyCatalog; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +/** + * Decides what happens to a request that arrives with an idempotency key, or that should have. + * + *

Written as a decision function rather than as a {@code ServerInterceptor} subclass, because + * the decision has to be reachable from both sides: the server takes it on an inbound call, and the + * client takes the same one before deciding whether a retry is safe. An interceptor that owns the + * logic leaves the client re-deriving it. + * + *

The ordering matters and is easy to get subtly wrong. The claim is attempted first, + * atomically; only then is the existing record examined. Reading first and claiming second leaves a + * window in which two attempts both read nothing. + */ +public final class GrpcIdempotencyInterceptor { + + private final GrpcOperationLedger ledger; + private final GrpcMethodPolicyCatalog catalog; + private final Duration inProgressPollInterval; + private final boolean waitForInProgress; + + /** + * @param inProgressPollInterval how long a duplicate is told to wait before asking again + * @param waitForInProgress whether a duplicate of a running operation waits or is refused. A + * method whose caller is a user-facing request usually wants to be refused quickly; a worker + * usually wants to wait. + */ + public GrpcIdempotencyInterceptor( + GrpcOperationLedger ledger, + GrpcMethodPolicyCatalog catalog, + Duration inProgressPollInterval, + boolean waitForInProgress) { + if (ledger == null || catalog == null) { + throw new IllegalArgumentException("an idempotency interceptor needs a ledger and a catalog"); + } + if (inProgressPollInterval == null || inProgressPollInterval.isNegative()) { + throw new IllegalArgumentException("a poll interval must be present and non-negative"); + } + if (waitForInProgress && inProgressPollInterval.isZero()) { + throw new IllegalArgumentException("waiting for an in-progress claim needs a poll interval"); + } + this.ledger = ledger; + this.catalog = catalog; + this.inProgressPollInterval = inProgressPollInterval; + this.waitForInProgress = waitForInProgress; + } + + /** + * The decision for one request. + * + * @param idempotencyKey the caller's key, or null when none was sent + * @throws IllegalArgumentException when a method that requires a key was called without one. A + * missing key is refused rather than defaulted: generating one server-side would make every + * retry a distinct operation, which is the opposite of what the caller asked for. + */ + public GrpcIdempotencyDecision decide( + GrpcMethodName method, + String callerFingerprint, + String idempotencyKey, + String requestFingerprint, + Instant now) { + GrpcMethodPolicy policy = catalog.require(method); + if (!policy.idempotency().idempotencyKeyRequired()) { + return GrpcIdempotencyDecision.proceed( + "method " + method.canonical() + " does not require an idempotency key"); + } + if (idempotencyKey == null || idempotencyKey.isBlank()) { + throw new IllegalArgumentException( + "method '" + + method.canonical() + + "' is IDEMPOTENCY_KEY_REQUIRED and was called without a key; a server-generated key " + + "would make every retry a new operation"); + } + + GrpcOperationIdentity identity = + new GrpcOperationIdentity( + callerFingerprint, method, GrpcRequestFingerprint.hashIdempotencyKey(idempotencyKey)); + Optional existing = ledger.claim(identity, requestFingerprint, now); + if (existing.isEmpty()) { + return GrpcIdempotencyDecision.proceed("claimed; this caller owns the operation"); + } + + GrpcOperationLedgerRecord record = existing.get(); + if (!record.sameRequestAs(requestFingerprint)) { + return GrpcIdempotencyDecision.fingerprintMismatch(); + } + if (record.state() == GrpcOperationLedgerState.COMMITTED) { + return GrpcIdempotencyDecision.replay( + record + .outcomeReference() + .orElseThrow( + () -> + new IllegalStateException( + "COMMITTED ledger record for '" + + identity.storageKey() + + "' has no outcome reference to replay"))); + } + if (record.state() == GrpcOperationLedgerState.FAILED_TERMINAL) { + // A terminal failure is not replayed: the caller asked for the operation, it did not happen, + // and repeating it is exactly what the key makes safe. + return GrpcIdempotencyDecision.proceed( + "the previous attempt failed terminally; the key makes repeating it safe"); + } + return waitForInProgress + ? GrpcIdempotencyDecision.waitAndPoll(inProgressPollInterval) + : GrpcIdempotencyDecision.rejectInProgress(); + } + + /** + * Records a commit against the claim. + * + *

Called from inside the business transaction wherever the datastore permits it. A ledger + * committed after the mutation leaves a window in which the write is durable and the claim is + * not. + */ + public void recordCommit( + GrpcMethodName method, + String callerFingerprint, + String idempotencyKey, + String outcomeReference, + Instant now) { + ledger.markCommitted( + new GrpcOperationIdentity( + callerFingerprint, method, GrpcRequestFingerprint.hashIdempotencyKey(idempotencyKey)), + outcomeReference, + now); + } + + /** Records a terminal failure against the claim. */ + public void recordFailure( + GrpcMethodName method, String callerFingerprint, String idempotencyKey, Instant now) { + ledger.markFailed( + new GrpcOperationIdentity( + callerFingerprint, method, GrpcRequestFingerprint.hashIdempotencyKey(idempotencyKey)), + now); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcOperationStatus.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcOperationStatus.java new file mode 100644 index 00000000..24eef003 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcOperationStatus.java @@ -0,0 +1,32 @@ +package dev.caskeleton.grpc.idempotency; + +/** + * What a status query found out about an operation. + * + *

{@link #NOT_FOUND} and {@link #UNKNOWN} are different answers and keeping them apart is the + * whole value of the enum. NOT_FOUND means the ledger was reachable and has no claim, so the + * operation never started; UNKNOWN means the ledger could not be consulted. Treating the second as + * the first is how a committed operation gets run again. + */ +public enum GrpcOperationStatus { + /** A claim exists and has not finished. */ + IN_PROGRESS, + /** The operation committed. */ + COMMITTED, + /** The operation failed in a way that will not succeed on repetition. */ + FAILED_TERMINAL, + /** The ledger was consulted and holds no claim. The operation never started. */ + NOT_FOUND, + /** The ledger could not be consulted. Nothing may be concluded. */ + UNKNOWN; + + /** Whether this answer is firm enough to act on. */ + public boolean conclusive() { + return this != UNKNOWN; + } + + /** Whether re-issuing the mutation is safe on this answer alone. */ + public boolean safeToReissue() { + return this == NOT_FOUND || this == FAILED_TERMINAL; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcOperationStatusQuery.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcOperationStatusQuery.java new file mode 100644 index 00000000..678d54ab --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcOperationStatusQuery.java @@ -0,0 +1,90 @@ +package dev.caskeleton.grpc.idempotency; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; +import dev.caskeleton.grpc.ledger.GrpcOperationIdentity; +import dev.caskeleton.grpc.ledger.GrpcOperationLedger; +import dev.caskeleton.grpc.ledger.GrpcOperationLedgerRecord; +import java.time.Duration; +import java.util.Optional; + +/** + * Asks the ledger what happened to an operation whose result the caller never saw. + * + *

Read-only, and it runs under its own short deadline profile rather than the mutation's. That + * is not a detail: the query is issued precisely when the mutation's deadline has already elapsed, + * so inheriting it would mean the recovery path has no time either. + * + *

A ledger failure produces {@link GrpcOperationStatus#UNKNOWN} rather than an exception. The + * caller is already in the unknown-result case; turning "we could not check" into a thrown error + * loses the distinction from "we checked and there is nothing". + */ +public final class GrpcOperationStatusQuery { + + private final GrpcOperationLedger ledger; + private final GrpcDeadlineProfile queryDeadline; + + /** Binds a query to the ledger and its own read-only deadline profile. */ + public GrpcOperationStatusQuery(GrpcOperationLedger ledger, GrpcDeadlineProfile queryDeadline) { + if (ledger == null || queryDeadline == null) { + throw new IllegalArgumentException( + "a status query needs a ledger and its own deadline profile"); + } + this.ledger = ledger; + this.queryDeadline = queryDeadline; + } + + /** A query with the platform default: a two-second read-only budget. */ + public static GrpcOperationStatusQuery withDefaultDeadline(GrpcOperationLedger ledger) { + return new GrpcOperationStatusQuery(ledger, GrpcDeadlineProfile.of(Duration.ofSeconds(2))); + } + + /** The deadline profile this query runs under. */ + public GrpcDeadlineProfile queryDeadline() { + return queryDeadline; + } + + /** What the ledger says about this operation. */ + public GrpcCompletionResolution resolve( + GrpcMethodName method, String callerFingerprint, String idempotencyKey) { + GrpcOperationIdentity identity; + try { + identity = + new GrpcOperationIdentity( + callerFingerprint, method, GrpcRequestFingerprint.hashIdempotencyKey(idempotencyKey)); + } catch (IllegalArgumentException malformed) { + return GrpcCompletionResolution.unknown( + "the operation identity could not be reconstructed: " + malformed.getMessage()); + } + + Optional record; + try { + record = ledger.find(identity); + } catch (RuntimeException ledgerUnavailable) { + return GrpcCompletionResolution.unknown( + "the ledger could not be consulted; nothing may be concluded about this operation"); + } + if (record.isEmpty()) { + return GrpcCompletionResolution.of( + GrpcOperationStatus.NOT_FOUND, + Optional.empty(), + "no claim exists, so the operation never started"); + } + GrpcOperationLedgerRecord found = record.get(); + return switch (found.state()) { + case IN_PROGRESS -> + GrpcCompletionResolution.of( + GrpcOperationStatus.IN_PROGRESS, Optional.empty(), "a claim is still running"); + case COMMITTED -> + GrpcCompletionResolution.of( + GrpcOperationStatus.COMMITTED, + found.outcomeReference(), + "the operation committed; return the stored outcome rather than repeating it"); + case FAILED_TERMINAL -> + GrpcCompletionResolution.of( + GrpcOperationStatus.FAILED_TERMINAL, + Optional.empty(), + "the operation failed terminally and may be re-issued"); + }; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcOutcomeReplay.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcOutcomeReplay.java new file mode 100644 index 00000000..6d60c35f --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcOutcomeReplay.java @@ -0,0 +1,62 @@ +package dev.caskeleton.grpc.idempotency; + +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Returns the stored answer for an operation that already committed. + * + *

Separated from the ledger because the two have different size problems. The ledger row must + * stay small and is written inside the business transaction; a response may be large and is only + * needed if somebody actually retries. So the ledger stores a reference and this resolves it, + * against whatever the deployment chose — a small inline store, an object store, or a re-read of + * the committed resource. + */ +public final class GrpcOutcomeReplay { + + private final ConcurrentMap storedOutcomes = new ConcurrentHashMap<>(); + private final int maxInlineBytes; + + /** + * @param maxInlineBytes the largest response stored inline; anything larger must be kept behind a + * reference in an object store instead, which is why {@link #store} refuses it rather than + * silently truncating + */ + public GrpcOutcomeReplay(int maxInlineBytes) { + if (maxInlineBytes < 1) { + throw new IllegalArgumentException("an inline outcome store needs a positive size limit"); + } + this.maxInlineBytes = maxInlineBytes; + } + + /** Stores a response under {@code outcomeReference}. */ + public void store(String outcomeReference, byte[] serializedResponse) { + if (outcomeReference == null || outcomeReference.isBlank()) { + throw new IllegalArgumentException("an outcome needs a reference to be stored under"); + } + if (serializedResponse == null) { + throw new IllegalArgumentException("an outcome needs a serialized response"); + } + if (serializedResponse.length > maxInlineBytes) { + throw new IllegalArgumentException( + "response of " + + serializedResponse.length + + " bytes exceeds the inline outcome limit of " + + maxInlineBytes + + "; store it behind an object reference instead"); + } + storedOutcomes.put(outcomeReference, serializedResponse.clone()); + } + + /** The stored response, if there is one. */ + public Optional replay(String outcomeReference) { + byte[] stored = storedOutcomes.get(outcomeReference); + return Optional.ofNullable(stored).map(byte[]::clone); + } + + /** How many outcomes are held. */ + public int size() { + return storedOutcomes.size(); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcRequestFingerprint.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcRequestFingerprint.java new file mode 100644 index 00000000..abd4ffe4 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcRequestFingerprint.java @@ -0,0 +1,55 @@ +package dev.caskeleton.grpc.idempotency; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * A stable hash of a request, used to tell "the same call again" from "a different call reusing a + * key". + * + *

Hashed rather than stored, for two reasons that both matter. A stored request body is a copy + * of the payload in a table nobody thinks of as holding payloads, and it is unbounded — a ledger + * row would grow with the largest message the method accepts. + */ +public final class GrpcRequestFingerprint { + + private static final String ALGORITHM = "SHA-256"; + private static final String PREFIX = "sha256:"; + + private GrpcRequestFingerprint() {} + + /** The fingerprint of {@code serializedRequest}. */ + public static String of(byte[] serializedRequest) { + if (serializedRequest == null) { + throw new IllegalArgumentException("a fingerprint needs the serialized request"); + } + return PREFIX + HexFormat.of().formatHex(digest().digest(serializedRequest)); + } + + /** The fingerprint of a textual request representation. */ + public static String of(String canonicalRequest) { + if (canonicalRequest == null) { + throw new IllegalArgumentException("a fingerprint needs a canonical request representation"); + } + return of(canonicalRequest.getBytes(StandardCharsets.UTF_8)); + } + + /** The hash of a caller-supplied idempotency key, which is never stored in the clear. */ + public static String hashIdempotencyKey(String idempotencyKey) { + if (idempotencyKey == null || idempotencyKey.isBlank()) { + throw new IllegalArgumentException("an idempotency key must not be blank"); + } + return of(idempotencyKey); + } + + private static MessageDigest digest() { + try { + return MessageDigest.getInstance(ALGORITHM); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is required of every JRE, so this cannot happen on a conforming platform. + throw new IllegalStateException(ALGORITHM + " is unavailable on this JVM", e); + } + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/policy/GrpcCompressionProfile.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/policy/GrpcCompressionProfile.java new file mode 100644 index 00000000..5ba69029 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/policy/GrpcCompressionProfile.java @@ -0,0 +1,50 @@ +package dev.caskeleton.grpc.policy; + +/** + * Which compression a method uses, and the ratio beyond which a message is treated as an attack. + * + *

The ratio bound is the part that is easy to leave out. A four-megabyte inbound limit applied + * only to the compressed size lets a few hundred kilobytes of zeros decompress into gigabytes, and + * the allocation happens before any application code sees the message. Checking the ratio is what + * turns a decompression bomb into a rejected request. + * + *

{@link #IDENTITY} on a method that carries already-compressed bytes is not an oversight + * either: gzipping a JPEG spends CPU to make the payload slightly larger. + */ +public enum GrpcCompressionProfile { + /** No compression. For already-compressed payloads and for small messages. */ + IDENTITY(false, 1), + /** gzip, with a bounded expansion ratio. */ + GZIP(true, 50); + + private final boolean compressed; + private final int maxDecompressionRatio; + + GrpcCompressionProfile(boolean compressed, int maxDecompressionRatio) { + this.compressed = compressed; + this.maxDecompressionRatio = maxDecompressionRatio; + } + + /** Whether messages are compressed. */ + public boolean compressed() { + return compressed; + } + + /** The largest decompressed-to-compressed ratio this profile accepts. */ + public int maxDecompressionRatio() { + return maxDecompressionRatio; + } + + /** + * Whether {@code compressedBytes} expanding to {@code decompressedBytes} is within the ratio. + * + *

Checked before the decompressed buffer is allocated, which is the only point at which it + * helps. + */ + public boolean ratioAcceptable(long compressedBytes, long decompressedBytes) { + if (compressedBytes <= 0L) { + return decompressedBytes == 0L; + } + return decompressedBytes <= compressedBytes * (long) maxDecompressionRatio; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/policy/GrpcMessageSizeProfile.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/policy/GrpcMessageSizeProfile.java new file mode 100644 index 00000000..28fd2dee --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/policy/GrpcMessageSizeProfile.java @@ -0,0 +1,57 @@ +package dev.caskeleton.grpc.policy; + +/** + * A method's size bounds, chosen rather than inherited. + * + *

gRPC's default inbound limit is four megabytes and Protobuf's theoretical maximum is two + * gigabytes; neither is a decision about this method. A profile that states a bound is a profile + * somebody sized against the messages the method actually carries, and it is what makes an + * oversized request a clean rejection instead of a heap that fills up under load. + * + *

Large binary does not get a bigger bound. It gets a reference — see {@link + * GrpcPayloadBoundaryPolicy}. + */ +public record GrpcMessageSizeProfile( + long maxInboundMessageBytes, + long maxOutboundMessageBytes, + long maxMetadataBytes, + int maxCollectionElements, + int maxFieldLength, + int maxNestingDepth, + GrpcCompressionProfile compression) { + + /** Refuses an unbounded or incoherent profile. */ + public GrpcMessageSizeProfile { + if (maxInboundMessageBytes < 1L || maxOutboundMessageBytes < 1L || maxMetadataBytes < 1L) { + throw new IllegalArgumentException("every size bound must be positive"); + } + if (maxCollectionElements < 1 || maxFieldLength < 1 || maxNestingDepth < 1) { + throw new IllegalArgumentException("every shape bound must be positive"); + } + if (compression == null) { + throw new IllegalArgumentException("a size profile names its compression"); + } + if (maxNestingDepth > 32) { + throw new IllegalArgumentException( + "a nesting depth above 32 lets a small message drive deep recursion during parsing"); + } + } + + /** The Stable default for an ordinary RPC: 1 MiB messages, no compression. */ + public static GrpcMessageSizeProfile standard() { + return new GrpcMessageSizeProfile( + 1024L * 1024L, 1024L * 1024L, 8192L, 1000, 8192, 8, GrpcCompressionProfile.IDENTITY); + } + + /** An explicitly reviewed large-message profile: 8 MiB, gzip. */ + public static GrpcMessageSizeProfile largeMessageOptIn() { + return new GrpcMessageSizeProfile( + 8L * 1024L * 1024L, + 8L * 1024L * 1024L, + 8192L, + 10_000, + 65_536, + 12, + GrpcCompressionProfile.GZIP); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/policy/GrpcPayloadBoundaryPolicy.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/policy/GrpcPayloadBoundaryPolicy.java new file mode 100644 index 00000000..82f1a3cd --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/policy/GrpcPayloadBoundaryPolicy.java @@ -0,0 +1,161 @@ +package dev.caskeleton.grpc.policy; + +import java.util.ArrayList; +import java.util.List; + +/** + * Checks a message against its size profile, and refuses to let large binary travel inline. + * + *

The binary rule is the one with an architectural reason behind it rather than a resource one. + * This repository already has a file server and an object store; a method that accepts a file as + * bytes duplicates their responsibility, loses their resumability and lifecycle, and puts the file + * in a request that has to be buffered whole to be parsed. So a payload over the binary threshold + * is a reference, and this policy is where that is refused rather than reviewed. + */ +public final class GrpcPayloadBoundaryPolicy { + + private final GrpcMessageSizeProfile profile; + private final long inlineBinaryThresholdBytes; + + /** + * @param inlineBinaryThresholdBytes the largest {@code bytes} field that may travel inline; + * anything larger is passed as a file server or object storage reference + */ + public GrpcPayloadBoundaryPolicy( + GrpcMessageSizeProfile profile, long inlineBinaryThresholdBytes) { + if (profile == null) { + throw new IllegalArgumentException("a payload policy needs a size profile"); + } + if (inlineBinaryThresholdBytes < 1L) { + throw new IllegalArgumentException("the inline binary threshold must be positive"); + } + if (inlineBinaryThresholdBytes > profile.maxInboundMessageBytes()) { + throw new IllegalArgumentException( + "an inline binary threshold above the message bound can never be reached"); + } + this.profile = profile; + this.inlineBinaryThresholdBytes = inlineBinaryThresholdBytes; + } + + /** A message's measured shape, as the transport sees it before parsing the body. */ + public record Measurement( + long compressedBytes, + long uncompressedBytes, + long metadataBytes, + int largestCollectionSize, + int largestFieldLength, + int nestingDepth, + long largestBinaryFieldBytes) { + + /** Requires non-negative measurements. */ + public Measurement { + if (compressedBytes < 0L + || uncompressedBytes < 0L + || metadataBytes < 0L + || largestCollectionSize < 0 + || largestFieldLength < 0 + || nestingDepth < 0 + || largestBinaryFieldBytes < 0L) { + throw new IllegalArgumentException("measurements must not be negative"); + } + } + } + + /** + * Every bound {@code measurement} breaks. + * + * @return an empty list when the message is within its profile + */ + public List check(Measurement measurement) { + if (measurement == null) { + throw new IllegalArgumentException("a measurement is required"); + } + List violations = new ArrayList<>(); + + // Ratio first: it is the only check that must happen before the decompressed buffer exists. + if (profile.compression().compressed() + && !profile + .compression() + .ratioAcceptable(measurement.compressedBytes(), measurement.uncompressedBytes())) { + violations.add( + new GrpcSizeViolation( + GrpcSizeViolation.Bound.DECOMPRESSION_RATIO, + "message", + measurement.uncompressedBytes(), + measurement.compressedBytes() * profile.compression().maxDecompressionRatio())); + } + addIfExceeded( + violations, + GrpcSizeViolation.Bound.COMPRESSED_MESSAGE, + "message", + measurement.compressedBytes(), + profile.maxInboundMessageBytes()); + addIfExceeded( + violations, + GrpcSizeViolation.Bound.UNCOMPRESSED_MESSAGE, + "message", + measurement.uncompressedBytes(), + profile.maxInboundMessageBytes()); + addIfExceeded( + violations, + GrpcSizeViolation.Bound.METADATA, + "metadata", + measurement.metadataBytes(), + profile.maxMetadataBytes()); + addIfExceeded( + violations, + GrpcSizeViolation.Bound.COLLECTION_COUNT, + "repeated field", + measurement.largestCollectionSize(), + profile.maxCollectionElements()); + addIfExceeded( + violations, + GrpcSizeViolation.Bound.FIELD_LENGTH, + "string or bytes field", + measurement.largestFieldLength(), + profile.maxFieldLength()); + addIfExceeded( + violations, + GrpcSizeViolation.Bound.NESTING_DEPTH, + "message nesting", + measurement.nestingDepth(), + profile.maxNestingDepth()); + return List.copyOf(violations); + } + + /** + * Whether a binary field of {@code bytes} must be passed as a reference instead. + * + *

Separate from {@link #check} because it is not a rejection by size — the message may be well + * within its bound — but a statement about which component owns the data. + */ + public boolean requiresObjectReference(long bytes) { + return bytes > inlineBinaryThresholdBytes; + } + + /** + * Whether compressing this payload again is worth doing. + * + *

False for a profile that already compresses when the content is itself compressed: the + * second pass costs CPU on both sides and typically grows the payload. + */ + public boolean shouldCompress(boolean contentAlreadyCompressed) { + return profile.compression().compressed() && !contentAlreadyCompressed; + } + + /** The profile in force. */ + public GrpcMessageSizeProfile profile() { + return profile; + } + + private static void addIfExceeded( + List violations, + GrpcSizeViolation.Bound bound, + String subject, + long observed, + long permitted) { + if (observed > permitted) { + violations.add(new GrpcSizeViolation(bound, subject, observed, permitted)); + } + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/policy/GrpcSizeViolation.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/policy/GrpcSizeViolation.java new file mode 100644 index 00000000..83961d91 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/policy/GrpcSizeViolation.java @@ -0,0 +1,46 @@ +package dev.caskeleton.grpc.policy; + +/** + * One size bound a message broke. + * + *

Carries the observed and permitted numbers, which are safe to expose: a byte count is not + * content. It carries no field value for the same reason {@code GrpcValidationViolation} does not. + */ +public record GrpcSizeViolation(Bound bound, String subject, long observed, long permitted) { + + /** Which bound was exceeded. */ + public enum Bound { + /** Serialized message size before compression. */ + UNCOMPRESSED_MESSAGE, + /** Serialized message size on the wire. */ + COMPRESSED_MESSAGE, + /** Total metadata size. */ + METADATA, + /** A repeated field's element count. */ + COLLECTION_COUNT, + /** A string or bytes field's length. */ + FIELD_LENGTH, + /** Message nesting depth. */ + NESTING_DEPTH, + /** The ratio between decompressed and compressed size. */ + DECOMPRESSION_RATIO + } + + /** Requires a subject and coherent numbers. */ + public GrpcSizeViolation { + if (bound == null) { + throw new IllegalArgumentException("a size violation names the bound it broke"); + } + if (subject == null || subject.isBlank()) { + throw new IllegalArgumentException("a size violation names what was too big"); + } + if (observed < 0 || permitted < 0) { + throw new IllegalArgumentException("size measurements must not be negative"); + } + } + + /** {@code bound subject: observed > permitted}, the form that is safe to log. */ + public String describe() { + return bound + " " + subject + ": " + observed + " > " + permitted; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcMethodRetryConfig.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcMethodRetryConfig.java new file mode 100644 index 00000000..5f53d407 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcMethodRetryConfig.java @@ -0,0 +1,99 @@ +package dev.caskeleton.grpc.resilience; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.GrpcStatusCode; +import java.time.Duration; +import java.util.Set; + +/** + * One method's retry configuration, as it would appear in a gRPC service config. + * + *

Distinguishes three states that a nullable config would collapse into one: a method with a + * retry policy, a method deliberately configured not to retry ({@link #disabled}), and a method + * with no entry at all. The middle one is the one that matters — "we decided not to retry this" and + * "nobody has configured this" produce identical behaviour and completely different conversations. + */ +public record GrpcMethodRetryConfig( + GrpcMethodName method, + int maxAttempts, + Duration initialBackoff, + Duration maxBackoff, + double backoffMultiplier, + double jitterFactor, + Set retryableStatusCodes) { + + /** Refuses an incoherent or unbounded retry configuration. */ + public GrpcMethodRetryConfig { + if (method == null) { + throw new IllegalArgumentException("a retry config names its method"); + } + if (maxAttempts < 1) { + throw new IllegalArgumentException( + "maxAttempts counts the first attempt; got " + maxAttempts); + } + if (maxAttempts > 5) { + throw new IllegalArgumentException( + "maxAttempts above 5 multiplies load on a dependency that is already failing; got " + + maxAttempts); + } + if (initialBackoff == null || maxBackoff == null) { + throw new IllegalArgumentException("a retry config needs both backoff bounds"); + } + if (initialBackoff.isNegative() || maxBackoff.compareTo(initialBackoff) < 0) { + throw new IllegalArgumentException("backoff bounds must be non-negative and ordered"); + } + if (backoffMultiplier < 1.0d) { + throw new IllegalArgumentException( + "a multiplier below 1 shortens each wait, which is the opposite of backoff"); + } + if (jitterFactor < 0.0d || jitterFactor > 1.0d) { + throw new IllegalArgumentException("jitter factor is a fraction between 0 and 1"); + } + if (retryableStatusCodes == null) { + throw new IllegalArgumentException("a retry config lists the codes it retries on"); + } + if (maxAttempts > 1 && retryableStatusCodes.isEmpty()) { + throw new IllegalArgumentException( + "a retry config with attempts but no retryable codes never retries while looking as if it does"); + } + if (retryableStatusCodes.contains(GrpcStatusCode.OK)) { + throw new IllegalArgumentException("OK is not a failure to retry"); + } + retryableStatusCodes = Set.copyOf(retryableStatusCodes); + } + + /** A method that is deliberately not retried. */ + public static GrpcMethodRetryConfig disabled(GrpcMethodName method) { + return new GrpcMethodRetryConfig(method, 1, Duration.ZERO, Duration.ZERO, 1.0d, 0.0d, Set.of()); + } + + /** The platform default for a read: three attempts on UNAVAILABLE, exponential with jitter. */ + public static GrpcMethodRetryConfig readDefault(GrpcMethodName method) { + return new GrpcMethodRetryConfig( + method, + 3, + Duration.ofMillis(100), + Duration.ofSeconds(2), + 2.0d, + 0.2d, + Set.of(GrpcStatusCode.UNAVAILABLE, GrpcStatusCode.RESOURCE_EXHAUSTED)); + } + + /** Whether this configuration ever issues a second attempt. */ + public boolean retriesAtAll() { + return maxAttempts > 1 && !retryableStatusCodes.isEmpty(); + } + + /** The un-jittered wait before attempt {@code attempt} (1-based; attempt 1 waits nothing). */ + public Duration backoffBefore(int attempt) { + if (attempt < 1) { + throw new IllegalArgumentException("attempt is 1-based"); + } + if (attempt == 1) { + return Duration.ZERO; + } + double millis = initialBackoff.toMillis() * Math.pow(backoffMultiplier, attempt - 2.0d); + Duration computed = Duration.ofMillis(Math.round(millis)); + return computed.compareTo(maxBackoff) > 0 ? maxBackoff : computed; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryBudget.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryBudget.java new file mode 100644 index 00000000..a661cfeb --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryBudget.java @@ -0,0 +1,76 @@ +package dev.caskeleton.grpc.resilience; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * A token budget that caps retries as a fraction of real traffic. + * + *

Per-call attempt limits do not bound total load: a dependency failing for every caller gets + * every caller's maximum attempts at once, which is the moment it can least afford them. A budget + * measured against successful calls degrades to roughly no retries exactly when everything is + * failing, which is the behaviour that lets a dependency recover. + */ +public final class GrpcRetryBudget { + + private final long maxTokens; + private final long tokensPerRetry; + private final long tokensPerSuccess; + private final AtomicLong tokens; + + /** + * A budget that starts full. + * + * @param ratio retries permitted per successful call, e.g. 0.2 for one retry per five successes + * @param maxTokens how much credit may accumulate, which bounds a burst after a quiet period + */ + public static GrpcRetryBudget of(double ratio, long maxTokens) { + if (ratio <= 0.0d || ratio > 1.0d) { + throw new IllegalArgumentException( + "a retry ratio is a fraction in (0, 1]; a budget that permits a retry per call is not a budget"); + } + if (maxTokens < 1) { + throw new IllegalArgumentException("a budget needs at least one token"); + } + long perRetry = Math.round(1.0d / ratio); + return new GrpcRetryBudget(maxTokens, perRetry, 1L); + } + + private GrpcRetryBudget(long maxTokens, long tokensPerRetry, long tokensPerSuccess) { + this.maxTokens = maxTokens; + this.tokensPerRetry = tokensPerRetry; + this.tokensPerSuccess = tokensPerSuccess; + this.tokens = new AtomicLong(maxTokens); + } + + /** + * Takes the credit for one retry, if there is any. + * + * @return false when the budget is exhausted, in which case the caller must not retry + */ + public boolean tryConsume() { + while (true) { + long observed = tokens.get(); + if (observed < tokensPerRetry) { + return false; + } + if (tokens.compareAndSet(observed, observed - tokensPerRetry)) { + return true; + } + } + } + + /** Records a successful call, which earns credit back up to the ceiling. */ + public void recordSuccess() { + tokens.updateAndGet(observed -> Math.min(maxTokens, observed + tokensPerSuccess)); + } + + /** How much credit is left. */ + public long availableTokens() { + return tokens.get(); + } + + /** Whether another retry could be afforded right now. */ + public boolean exhausted() { + return tokens.get() < tokensPerRetry; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryCoordinator.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryCoordinator.java new file mode 100644 index 00000000..c96684c3 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryCoordinator.java @@ -0,0 +1,126 @@ +package dev.caskeleton.grpc.resilience; + +import dev.caskeleton.grpc.core.GrpcStatusCode; +import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget; +import dev.caskeleton.grpc.evidence.GrpcExecutionEvidence; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import java.time.Duration; +import java.util.random.RandomGenerator; + +/** + * The single place a retry is decided. + * + *

Eligibility, attempt count, status match, remaining deadline and budget, checked in that order + * and answered with one {@link GrpcRetryDecision}. Splitting them across a client interceptor and a + * service config is what produces a retry that is individually justified by each layer and + * collectively wrong. + * + *

Jitter comes from an injected {@link RandomGenerator} so a test can assert the backoff + * schedule rather than a range. + */ +public final class GrpcRetryCoordinator { + + private final GrpcRetryOwner owner; + private final GrpcRetryBudget budget; + private final RandomGenerator jitterSource; + + /** Binds a coordinator to its owner, budget and jitter source. */ + public GrpcRetryCoordinator( + GrpcRetryOwner owner, GrpcRetryBudget budget, RandomGenerator jitterSource) { + if (owner == null || budget == null || jitterSource == null) { + throw new IllegalArgumentException( + "a retry coordinator needs an owner, a budget and a jitter source"); + } + this.owner = owner; + this.budget = budget; + this.jitterSource = jitterSource; + } + + /** + * Decides whether attempt {@code completedAttempt} should be followed by another. + * + * @param completedAttempt the 1-based attempt that just failed + */ + public GrpcRetryDecision decide( + GrpcMethodPolicy policy, + GrpcMethodRetryConfig config, + GrpcExecutionEvidence evidence, + GrpcStatusCode statusCode, + GrpcRetryEligibility.Capabilities capabilities, + int completedAttempt, + GrpcDeadlineBudget remaining) { + if (config == null || remaining == null) { + throw new IllegalArgumentException( + "a retry decision needs a config and the remaining budget"); + } + if (completedAttempt < 1) { + throw new IllegalArgumentException("attempt is 1-based"); + } + + GrpcRetryDecision ineligible = + GrpcRetryEligibility.refuseIfIneligible(policy, evidence, statusCode, capabilities, owner); + if (ineligible != null) { + return ineligible; + } + if (!config.retriesAtAll()) { + return GrpcRetryDecision.refuse( + GrpcRetryDecision.Verdict.TERMINAL, "this method is configured not to retry"); + } + if (!config.retryableStatusCodes().contains(statusCode)) { + return GrpcRetryDecision.refuse( + GrpcRetryDecision.Verdict.TERMINAL, + statusCode + " is not a retryable status for this method"); + } + if (completedAttempt >= config.maxAttempts()) { + return GrpcRetryDecision.refuse( + GrpcRetryDecision.Verdict.TERMINAL, + "attempt " + completedAttempt + " of " + config.maxAttempts() + " is the last"); + } + + Duration backoff = jittered(config.backoffBefore(completedAttempt + 1), config.jitterFactor()); + if (remaining.remaining().compareTo(backoff) <= 0) { + return GrpcRetryDecision.refuse( + GrpcRetryDecision.Verdict.TERMINAL, + "the remaining deadline is shorter than the backoff, so a retry could not finish"); + } + GrpcDeadlineBudget afterBackoff = + new GrpcDeadlineBudget(remaining.remaining().minus(backoff), remaining.profile()); + if (!afterBackoff.canStartDependencyCall()) { + return GrpcRetryDecision.refuse( + GrpcRetryDecision.Verdict.TERMINAL, + "too little time remains after backoff to be worth another attempt"); + } + if (!budget.tryConsume()) { + return GrpcRetryDecision.refuse( + GrpcRetryDecision.Verdict.BUDGET_EXHAUSTED, + "the retry budget is spent; retrying now would add load to a failing dependency"); + } + return GrpcRetryDecision.retry(backoff, "attempt " + (completedAttempt + 1) + " is permitted"); + } + + /** Records a successful call so the budget recovers. */ + public void recordSuccess() { + budget.recordSuccess(); + } + + /** The budget this coordinator spends. */ + public GrpcRetryBudget budget() { + return budget; + } + + private Duration jittered(Duration base, double jitterFactor) { + if (jitterFactor == 0.0d || base.isZero()) { + return base; + } + long millis = base.toMillis(); + long spread = Math.round(millis * jitterFactor); + if (spread == 0L) { + return base; + } + // Full-jitter within the spread, both directions, so a fleet that failed together does not + // retry together. + long offset = jitterSource.nextLong(-spread, spread + 1); + long jitteredMillis = Math.max(0L, millis + offset); + return Duration.ofMillis(jitteredMillis); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryDecision.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryDecision.java new file mode 100644 index 00000000..923b4349 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryDecision.java @@ -0,0 +1,64 @@ +package dev.caskeleton.grpc.resilience; + +import java.time.Duration; + +/** + * Whether to retry, and if not, why not. + * + *

The refusal reason is a value rather than a log line because the interesting refusals are not + * failures. "The result is unknown, resolve it first" is a normal, correct outcome that the caller + * has to act on differently from "this will fail the same way again", and a boolean cannot carry + * that difference. + */ +public record GrpcRetryDecision(Verdict verdict, Duration backoff, String reason) { + + /** What the coordinator decided. */ + public enum Verdict { + /** Retry after {@link GrpcRetryDecision#backoff()}. */ + RETRY, + /** Do not retry; the same call will fail the same way. */ + TERMINAL, + /** Do not retry; repeating it risks a duplicate effect. */ + UNSAFE_TO_REPEAT, + /** Do not retry; resolve the unknown result with a status query first. */ + RESOLVE_COMPLETION_FIRST, + /** Do not retry; the retry budget is spent. */ + BUDGET_EXHAUSTED, + /** Do not retry; this process is not the retry owner. */ + NOT_THE_OWNER + } + + /** Requires a backoff only on RETRY, and a reason always. */ + public GrpcRetryDecision { + if (verdict == null) { + throw new IllegalArgumentException("a retry decision has a verdict"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("a retry decision explains itself"); + } + if (backoff == null || backoff.isNegative()) { + throw new IllegalArgumentException("a retry decision carries a non-negative backoff"); + } + if (verdict != Verdict.RETRY && !backoff.isZero()) { + throw new IllegalArgumentException("only a RETRY verdict carries a backoff"); + } + } + + /** Retry after {@code backoff}. */ + public static GrpcRetryDecision retry(Duration backoff, String reason) { + return new GrpcRetryDecision(Verdict.RETRY, backoff, reason); + } + + /** Do not retry. */ + public static GrpcRetryDecision refuse(Verdict verdict, String reason) { + if (verdict == Verdict.RETRY) { + throw new IllegalArgumentException("use retry() for a RETRY verdict"); + } + return new GrpcRetryDecision(verdict, Duration.ZERO, reason); + } + + /** Whether the caller should attempt the call again. */ + public boolean shouldRetry() { + return verdict == Verdict.RETRY; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryEligibility.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryEligibility.java new file mode 100644 index 00000000..e0571ff1 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryEligibility.java @@ -0,0 +1,103 @@ +package dev.caskeleton.grpc.resilience; + +import dev.caskeleton.grpc.core.GrpcStatusCode; +import dev.caskeleton.grpc.evidence.GrpcExecutionEvidence; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import dev.caskeleton.grpc.policy.RpcIdempotencyProfile; + +/** + * Whether an attempt may be repeated at all, judged from the method, the evidence and the status + * together. + * + *

Each of the three alone gives the wrong answer. The status says {@code UNAVAILABLE}, which + * looks retryable; the evidence says the request was sent and nothing came back, which makes it + * not; the method says non-idempotent, which settles it. Systems that read only the status are the + * ones that charge a card twice. + */ +public final class GrpcRetryEligibility { + + private GrpcRetryEligibility() {} + + /** What a caller can offer towards making a repeat safe. */ + public record Capabilities(boolean idempotencyKeyPresent, boolean operationLedgerAvailable) { + + /** Neither a key nor a ledger. */ + public static Capabilities none() { + return new Capabilities(false, false); + } + + /** Both a caller-supplied key and a durable ledger behind it. */ + public static Capabilities keyedWithLedger() { + return new Capabilities(true, true); + } + } + + /** + * The eligibility verdict, before backoff and budget are considered. + * + * @return a refusing decision, or null when the attempt is eligible and the coordinator should go + * on to check backoff and budget + */ + public static GrpcRetryDecision refuseIfIneligible( + GrpcMethodPolicy policy, + GrpcExecutionEvidence evidence, + GrpcStatusCode statusCode, + Capabilities capabilities, + GrpcRetryOwner owner) { + if (policy == null + || evidence == null + || statusCode == null + || capabilities == null + || owner == null) { + throw new IllegalArgumentException( + "eligibility needs the policy, evidence, status, capabilities and owner"); + } + + if (!owner.explicitRetryInProcess()) { + return GrpcRetryDecision.refuse( + GrpcRetryDecision.Verdict.NOT_THE_OWNER, + "retry owner is " + owner + "; this process must not also retry"); + } + if (!policy.idempotency().explicitRetryAllowed()) { + return GrpcRetryDecision.refuse( + GrpcRetryDecision.Verdict.UNSAFE_TO_REPEAT, + "method is " + policy.idempotency() + " and may not be retried explicitly"); + } + if (evidence.stream().delivered()) { + return GrpcRetryDecision.refuse( + GrpcRetryDecision.Verdict.UNSAFE_TO_REPEAT, + "a stream prefix was already delivered; a whole-call retry would redeliver it"); + } + if (policy.idempotency() == RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED + && !(capabilities.idempotencyKeyPresent() && capabilities.operationLedgerAvailable())) { + return GrpcRetryDecision.refuse( + GrpcRetryDecision.Verdict.UNSAFE_TO_REPEAT, + "IDEMPOTENCY_KEY_REQUIRED needs both a caller key and a durable ledger before a repeat is safe"); + } + if (isMutation(policy) && statusCode == GrpcStatusCode.DEADLINE_EXCEEDED) { + return GrpcRetryDecision.refuse( + GrpcRetryDecision.Verdict.RESOLVE_COMPLETION_FIRST, + "a mutation that ran out of time may have committed; resolve the result before repeating it"); + } + if (isMutation(policy) + && statusCode == GrpcStatusCode.UNAVAILABLE + && !evidence.transport().provesNotStarted()) { + return GrpcRetryDecision.refuse( + GrpcRetryDecision.Verdict.RESOLVE_COMPLETION_FIRST, + "UNAVAILABLE after the request was sent does not prove the server never ran it"); + } + if (!evidence.business().safeToRepeatWithoutGuard() + && !capabilities.operationLedgerAvailable()) { + return GrpcRetryDecision.refuse( + GrpcRetryDecision.Verdict.UNSAFE_TO_REPEAT, + "business evidence is " + + evidence.business() + + " and there is no ledger to guard a repeat"); + } + return null; + } + + private static boolean isMutation(GrpcMethodPolicy policy) { + return policy.idempotency() != RpcIdempotencyProfile.READ_ONLY; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryOwner.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryOwner.java new file mode 100644 index 00000000..49a494de --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryOwner.java @@ -0,0 +1,39 @@ +package dev.caskeleton.grpc.resilience; + +/** + * Who retries, of the three layers that can. + * + *

Exactly one, and the reason is multiplicative. Application-level retry three times over a + * channel configured for three attempts, behind a mesh that also retries three times, is + * twenty-seven requests for one call — and the load arrives precisely when the dependency is + * already failing. + * + *

{@link #NONE} is a real answer, distinct from "nobody got round to configuring it". A method + * whose owner is NONE has been looked at. + */ +public enum GrpcRetryOwner { + /** The application decides, usually because a retry needs business context. */ + APPLICATION(true), + /** The gRPC channel's service config decides. */ + GRPC_PLATFORM(true), + /** A service mesh decides, and the application must not also. */ + SERVICE_MESH(false), + /** Nobody retries. A deliberate choice, not an omission. */ + NONE(false); + + private final boolean explicitRetryInProcess; + + GrpcRetryOwner(boolean explicitRetryInProcess) { + this.explicitRetryInProcess = explicitRetryInProcess; + } + + /** Whether this process may issue explicit retries. */ + public boolean explicitRetryInProcess() { + return explicitRetryInProcess; + } + + /** Whether hedging may be configured under this owner. */ + public boolean hedgingAllowed() { + return this == GRPC_PLATFORM; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryOwnershipValidator.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryOwnershipValidator.java new file mode 100644 index 00000000..aac2dccf --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcRetryOwnershipValidator.java @@ -0,0 +1,79 @@ +package dev.caskeleton.grpc.resilience; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import dev.caskeleton.grpc.policy.GrpcMethodPolicyCatalog; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Checks a service config against the method policy catalog at startup. + * + *

The check that earns its keep is the name comparison. A service config keyed by method name is + * a string map with no compiler behind it: rename {@code CreateDocument} to {@code + * CreateDocumentV2} and the entry stops matching, silently, and the method runs with the channel + * default instead of the retry policy somebody wrote for it. Nothing fails, nothing logs, and the + * behaviour changed. + */ +public final class GrpcRetryOwnershipValidator { + + private GrpcRetryOwnershipValidator() {} + + /** + * Every disagreement between a service config and the catalog it claims to configure. + * + * @return an empty list when the two agree + */ + public static List validate( + GrpcServiceConfigPolicy serviceConfig, GrpcMethodPolicyCatalog catalog) { + if (serviceConfig == null || catalog == null) { + throw new IllegalArgumentException("validation needs a service config and a catalog"); + } + List violations = new ArrayList<>(); + + Set unknown = new LinkedHashSet<>(serviceConfig.methodConfigs().keySet()); + unknown.removeAll(catalog.methods()); + unknown.stream() + .sorted(java.util.Comparator.comparing(GrpcMethodName::canonical)) + .forEach( + method -> + violations.add( + "service config entry '" + + method.canonical() + + "' names a method the policy catalog does not declare; a renamed method " + + "leaves its retry entry matching nothing")); + + serviceConfig.methodConfigs().entrySet().stream() + .sorted(java.util.Comparator.comparing(entry -> entry.getKey().canonical())) + .forEach( + entry -> { + GrpcMethodPolicy policy = catalog.find(entry.getKey()).orElse(null); + if (policy == null) { + return; + } + GrpcMethodRetryConfig config = entry.getValue(); + if (config.retriesAtAll() && !policy.idempotency().explicitRetryAllowed()) { + violations.add( + "method '" + + entry.getKey().canonical() + + "' is " + + policy.idempotency() + + " and must not carry an explicit retry policy"); + } + if (config.retriesAtAll() && policy.rpcType().streaming()) { + violations.add( + "method '" + + entry.getKey().canonical() + + "' streams; a whole-call retry would redeliver a delivered prefix"); + } + }); + + if (serviceConfig.retryOwner() == GrpcRetryOwner.SERVICE_MESH + && serviceConfig.hedgingAllowed()) { + violations.add("a mesh-owned channel may not also configure hedging in-process"); + } + return List.copyOf(violations); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcServiceConfigPolicy.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcServiceConfigPolicy.java new file mode 100644 index 00000000..3abcd702 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcServiceConfigPolicy.java @@ -0,0 +1,73 @@ +package dev.caskeleton.grpc.resilience; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import java.util.Map; +import java.util.Optional; + +/** + * A channel's retry configuration and the single owner responsible for it. + * + *

Also records whether the transport's own transparent retry is in play. Transparent retry is + * not configurable and is safe by construction — it only replays a request the server provably + * never saw — but an operator reading "retry owner: NONE" and then seeing two requests in a backend + * log needs the snapshot to have said so. + */ +public record GrpcServiceConfigPolicy( + GrpcRetryOwner retryOwner, + Map methodConfigs, + boolean transparentRetryPresent) { + + /** Refuses a configuration whose owner contradicts its contents. */ + public GrpcServiceConfigPolicy { + if (retryOwner == null) { + throw new IllegalArgumentException("a service config names exactly one retry owner"); + } + if (methodConfigs == null) { + throw new IllegalArgumentException( + "a service config carries its method entries, even if empty"); + } + methodConfigs = Map.copyOf(methodConfigs); + if (!retryOwner.explicitRetryInProcess()) { + methodConfigs.values().stream() + .filter(GrpcMethodRetryConfig::retriesAtAll) + .findFirst() + .ifPresent( + config -> { + throw new IllegalArgumentException( + "retry owner is " + + retryOwner + + " but method '" + + config.method().canonical() + + "' configures " + + config.maxAttempts() + + " attempts in-process; two owners multiply the load on a dependency that " + + "is already failing"); + }); + } + } + + /** A mesh-owned channel: no in-process retry, no hedging. */ + public static GrpcServiceConfigPolicy meshOwned( + Map disabledConfigs) { + return new GrpcServiceConfigPolicy(GrpcRetryOwner.SERVICE_MESH, disabledConfigs, true); + } + + /** The entry for {@code method}, or empty when the method has none at all. */ + public Optional configFor(GrpcMethodName method) { + return Optional.ofNullable(methodConfigs.get(method)); + } + + /** + * Whether {@code method} was deliberately configured not to retry. + * + *

Distinct from having no entry, which is what {@link #configFor} returning empty means. + */ + public boolean explicitlyDisabled(GrpcMethodName method) { + return configFor(method).map(config -> !config.retriesAtAll()).orElse(false); + } + + /** Whether hedging may be configured on this channel. */ + public boolean hedgingAllowed() { + return retryOwner.hedgingAllowed(); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcWaitForReadyDecision.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcWaitForReadyDecision.java new file mode 100644 index 00000000..08f89bd0 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcWaitForReadyDecision.java @@ -0,0 +1,40 @@ +package dev.caskeleton.grpc.resilience; + +import java.time.Duration; + +/** + * Whether a call may queue for a disconnected channel, and for how long. + * + *

Carries the queue allowance separately from the call deadline so that time spent waiting for a + * connection is measurable on its own. Folded into call duration, it is invisible: a p99 that + * doubles during a rollout looks like the server got slower, when what happened is that callers + * queued. + */ +public record GrpcWaitForReadyDecision(boolean queue, Duration maxQueueWait, String reason) { + + /** Requires a reason, and an allowance only when queueing. */ + public GrpcWaitForReadyDecision { + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("a wait-for-ready decision explains itself"); + } + if (maxQueueWait == null || maxQueueWait.isNegative()) { + throw new IllegalArgumentException("a queue allowance must be present and non-negative"); + } + if (!queue && !maxQueueWait.isZero()) { + throw new IllegalArgumentException("a fail-fast decision carries no queue allowance"); + } + if (queue && maxQueueWait.isZero()) { + throw new IllegalArgumentException("a queueing decision carries a positive allowance"); + } + } + + /** Fail fast. */ + public static GrpcWaitForReadyDecision failFast(String reason) { + return new GrpcWaitForReadyDecision(false, Duration.ZERO, reason); + } + + /** Queue for at most {@code maxQueueWait}. */ + public static GrpcWaitForReadyDecision queueFor(Duration maxQueueWait, String reason) { + return new GrpcWaitForReadyDecision(true, maxQueueWait, reason); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcWaitForReadyProfile.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcWaitForReadyProfile.java new file mode 100644 index 00000000..c9a07d74 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcWaitForReadyProfile.java @@ -0,0 +1,54 @@ +package dev.caskeleton.grpc.resilience; + +import dev.caskeleton.grpc.policy.WaitForReadyPolicy; +import java.time.Duration; + +/** + * Wait-for-ready with the bound that makes it safe. + * + *

The policy alone says whether a call queues; the queue budget says for how long. They are + * separate because the deadline is not the right bound: a call that spends its entire deadline + * queued and then fails has consumed the full latency budget and produced nothing, and a caller + * would almost always rather fail fast at some point well before that and try something else. + */ +public record GrpcWaitForReadyProfile( + WaitForReadyPolicy policy, Duration maxQueueWait, boolean approvedForUserSynchronous) { + + /** Refuses a profile that queues without a bound, or a user-facing one without approval. */ + public GrpcWaitForReadyProfile { + if (policy == null) { + throw new IllegalArgumentException("a wait-for-ready profile names its policy"); + } + if (maxQueueWait == null || maxQueueWait.isNegative()) { + throw new IllegalArgumentException("a queue budget must be present and non-negative"); + } + if (policy.queues() && maxQueueWait.isZero()) { + throw new IllegalArgumentException( + "wait-for-ready without a queue budget waits until the deadline and then fails anyway"); + } + if (policy == WaitForReadyPolicy.APPROVED_SYNCHRONOUS && !approvedForUserSynchronous) { + throw new IllegalArgumentException( + "a user-synchronous path may only queue with an explicit approval; queueing turns a fast " + + "failure into the full deadline of latency"); + } + if (!policy.queues() && approvedForUserSynchronous) { + throw new IllegalArgumentException( + "an approval to queue on a profile that does not queue records a decision nobody made"); + } + } + + /** The default: fail fast. */ + public static GrpcWaitForReadyProfile disabled() { + return new GrpcWaitForReadyProfile(WaitForReadyPolicy.DISABLED, Duration.ZERO, false); + } + + /** A worker or batch path opting in within a queue budget. */ + public static GrpcWaitForReadyProfile worker(Duration maxQueueWait) { + return new GrpcWaitForReadyProfile(WaitForReadyPolicy.WORKER_OPT_IN, maxQueueWait, false); + } + + /** A reviewed and approved user-synchronous path. */ + public static GrpcWaitForReadyProfile approvedSynchronous(Duration maxQueueWait) { + return new GrpcWaitForReadyProfile(WaitForReadyPolicy.APPROVED_SYNCHRONOUS, maxQueueWait, true); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcWaitForReadyValidator.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcWaitForReadyValidator.java new file mode 100644 index 00000000..e1167cfc --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/resilience/GrpcWaitForReadyValidator.java @@ -0,0 +1,56 @@ +package dev.caskeleton.grpc.resilience; + +import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import java.time.Duration; + +/** + * Applies a wait-for-ready profile to one call. + * + *

Refuses to queue without a deadline, which is the combination that turns a transient outage + * into a thread pool full of calls that will never return. gRPC allows it; this platform does not. + */ +public final class GrpcWaitForReadyValidator { + + private GrpcWaitForReadyValidator() {} + + /** + * Decides whether this call queues. + * + * @param remaining the call's remaining deadline, or null when none was propagated + * @throws IllegalArgumentException when a queueing profile meets a call with no deadline + */ + public static GrpcWaitForReadyDecision decide( + GrpcMethodPolicy policy, GrpcWaitForReadyProfile profile, GrpcDeadlineBudget remaining) { + if (policy == null || profile == null) { + throw new IllegalArgumentException( + "a wait-for-ready decision needs a method policy and a profile"); + } + if (!profile.policy().queues()) { + return GrpcWaitForReadyDecision.failFast("wait-for-ready is disabled for this method"); + } + if (remaining == null) { + throw new IllegalArgumentException( + "wait-for-ready without a deadline queues forever; method '" + + policy.method().canonical() + + "' must carry one"); + } + if (remaining.expired()) { + return GrpcWaitForReadyDecision.failFast("no deadline remains to queue inside"); + } + if (policy.waitForReady() != profile.policy()) { + return GrpcWaitForReadyDecision.failFast( + "the method policy says " + + policy.waitForReady() + + " while the channel profile says " + + profile.policy() + + "; the stricter of the two wins"); + } + Duration allowance = + profile.maxQueueWait().compareTo(remaining.remaining()) <= 0 + ? profile.maxQueueWait() + : remaining.remaining(); + return GrpcWaitForReadyDecision.queueFor( + allowance, "queueing under " + profile.policy() + " within the queue budget"); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/security/GrpcAuthenticationProfile.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/security/GrpcAuthenticationProfile.java new file mode 100644 index 00000000..a159691f --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/security/GrpcAuthenticationProfile.java @@ -0,0 +1,59 @@ +package dev.caskeleton.grpc.security; + +/** + * How a caller proves who it is. + * + *

Every token-bearing profile requires {@code CallCredentials} rather than a header the + * application sets. The difference is not stylistic: {@code CallCredentials} is asked for a value + * per attempt, so a rotated or refreshed token reaches a retry, while a header captured when the + * stub was built is the token that was valid when the process started. + */ +public enum GrpcAuthenticationProfile { + /** No caller identity. Only for methods that explicitly allow anonymous access. */ + NONE(false, false), + /** An opaque bearer token supplied per call by a credential provider. */ + BEARER_TOKEN(true, false), + /** A signed JWT supplied per call by a credential provider. */ + JWT(true, false), + /** A service-to-service token supplied per call by a credential provider. */ + SERVICE_TOKEN(true, false), + /** The peer certificate is the identity. Requires mTLS. */ + MUTUAL_TLS(false, true); + + private final boolean callCredentialsRequired; + private final boolean mutualTlsRequired; + + GrpcAuthenticationProfile(boolean callCredentialsRequired, boolean mutualTlsRequired) { + this.callCredentialsRequired = callCredentialsRequired; + this.mutualTlsRequired = mutualTlsRequired; + } + + /** Whether a per-call credential provider must supply the value. */ + public boolean callCredentialsRequired() { + return callCredentialsRequired; + } + + /** Whether this profile only works over mTLS. */ + public boolean mutualTlsRequired() { + return mutualTlsRequired; + } + + /** + * Fails when {@code tls} cannot carry this profile. + * + * @throws IllegalStateException when the profile needs mTLS and the transport does not have it, + * or when a token would travel in plaintext + */ + public void requireCompatible(GrpcTlsProfile tls) { + if (tls == null) { + throw new IllegalArgumentException("a TLS profile is required"); + } + if (mutualTlsRequired && !tls.mutualTls()) { + throw new IllegalStateException(this + " requires mTLS, and the transport does not use it"); + } + if (callCredentialsRequired && !tls.tlsEnabled()) { + throw new IllegalStateException( + this + " sends a token; sending one over plaintext hands it to anyone on the path"); + } + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/security/GrpcCredentialGeneration.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/security/GrpcCredentialGeneration.java new file mode 100644 index 00000000..3787809a --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/security/GrpcCredentialGeneration.java @@ -0,0 +1,47 @@ +package dev.caskeleton.grpc.security; + +import java.time.Instant; + +/** + * One issue of credential or certificate material, identified by a monotonic generation number. + * + *

Carries a reference, never the material. A generation is a thing that gets logged, compared + * and put in an admin snapshot; if it held the key, every one of those would be a disclosure. + * + *

The generation number is what makes rotation observable. Without it, "the certificate was + * replaced" and "some calls are still using the old one" are the same state, and the drain that + * separates them has nothing to key on. + */ +public record GrpcCredentialGeneration( + long generation, String materialReference, Instant issuedAt, Instant expiresAt) { + + /** Requires a positive generation, a reference and a validity window in the right order. */ + public GrpcCredentialGeneration { + if (generation < 1) { + throw new IllegalArgumentException("credential generations are 1-based; got " + generation); + } + if (materialReference == null || materialReference.isBlank()) { + throw new IllegalArgumentException( + "a credential generation carries a reference to its material, never the material"); + } + if (issuedAt == null || expiresAt == null) { + throw new IllegalArgumentException("a credential generation needs a validity window"); + } + if (!expiresAt.isAfter(issuedAt)) { + throw new IllegalArgumentException("a credential generation expires after it is issued"); + } + } + + /** Whether this generation is usable at {@code now}. */ + public boolean validAt(Instant now) { + if (now == null) { + throw new IllegalArgumentException("a validity check needs a moment"); + } + return !now.isBefore(issuedAt) && now.isBefore(expiresAt); + } + + /** Whether {@code candidate} would be a legal successor to this generation. */ + public boolean supersededBy(GrpcCredentialGeneration candidate) { + return candidate != null && candidate.generation() > generation; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/security/GrpcCredentialRotationManager.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/security/GrpcCredentialRotationManager.java new file mode 100644 index 00000000..bcf033f6 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/security/GrpcCredentialRotationManager.java @@ -0,0 +1,122 @@ +package dev.caskeleton.grpc.security; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Swaps credential material without dropping in-flight work. + * + *

Rotation is prepare-then-swap-then-drain, in that order. Replacing the material in place is + * what produces the failure this exists to avoid: every call that was mid-flight when the swap + * happened fails with an authentication error that looks, from the client, exactly like a + * credential that was never valid. + * + *

The manager holds generations, not sockets. What "drain" means concretely — finish the unary + * calls, signal the streams, then cancel — belongs to the channel runtime and to the drain + * coordinator; what belongs here is the record of which generation is current, which one is + * draining, and until when. + */ +public final class GrpcCredentialRotationManager { + + private final AtomicReference state; + private final Duration drainWindow; + + /** Current and draining generations, swapped atomically. */ + private record State( + GrpcCredentialGeneration current, GrpcCredentialGeneration draining, Instant drainDeadline) {} + + /** What a caller must do after a rotation. */ + public record RotationPlan( + GrpcCredentialGeneration activated, + Optional draining, + Optional drainDeadline) { + + /** Requires the activated generation and both Optionals. */ + public RotationPlan { + if (activated == null || draining == null || drainDeadline == null) { + throw new IllegalArgumentException( + "a rotation plan states what was activated and what drains"); + } + } + + /** Whether an older generation still has work on it. */ + public boolean requiresDrain() { + return draining.isPresent(); + } + } + + /** + * Starts at {@code initial}. + * + * @param drainWindow how long the superseded generation keeps serving in-flight work before it is + * cancelled. Separate from any deadline: a call that started under the old credential is + * entitled to finish, and a call that would outlive the window is entitled to be told. + */ + public GrpcCredentialRotationManager(GrpcCredentialGeneration initial, Duration drainWindow) { + if (initial == null) { + throw new IllegalArgumentException("a rotation manager starts from a generation"); + } + if (drainWindow == null || drainWindow.isNegative()) { + throw new IllegalArgumentException("a drain window must be present and non-negative"); + } + this.state = new AtomicReference<>(new State(initial, null, null)); + this.drainWindow = drainWindow; + } + + /** The generation new calls use. */ + public GrpcCredentialGeneration current() { + return state.get().current(); + } + + /** The superseded generation still serving in-flight work, if any. */ + public Optional draining() { + return Optional.ofNullable(state.get().draining()); + } + + /** + * Activates {@code next} and starts draining the previous generation. + * + * @throws IllegalArgumentException when {@code next} does not supersede the current generation — + * a rotation that goes backwards would reactivate material that was already replaced, and the + * usual reason for one is two rotators racing + */ + public RotationPlan rotate(GrpcCredentialGeneration next, Instant now) { + if (next == null || now == null) { + throw new IllegalArgumentException("a rotation needs a generation and a moment"); + } + State observed = state.get(); + if (observed.current().generation() == next.generation()) { + // Idempotent: re-applying the generation that is already current is a no-op rather than a + // second drain, because a retried rotation must not cancel the calls the first one admitted. + return new RotationPlan(observed.current(), Optional.empty(), Optional.empty()); + } + if (!observed.current().supersededBy(next)) { + throw new IllegalArgumentException( + "credential generation " + + next.generation() + + " does not supersede the current generation " + + observed.current().generation()); + } + Instant deadline = now.plus(drainWindow); + state.set(new State(next, observed.current(), deadline)); + return new RotationPlan(next, Optional.of(observed.current()), Optional.of(deadline)); + } + + /** + * Whether the draining generation's window has closed at {@code now}. + * + *

True means the remaining calls on it are cancelled rather than waited for. + */ + public boolean drainExpired(Instant now) { + State observed = state.get(); + return observed.drainDeadline() != null && !now.isBefore(observed.drainDeadline()); + } + + /** Forgets the draining generation once its work has finished or been cancelled. */ + public void completeDrain() { + State observed = state.get(); + state.set(new State(observed.current(), null, null)); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/security/GrpcTlsProfile.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/security/GrpcTlsProfile.java new file mode 100644 index 00000000..26184e2a --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/security/GrpcTlsProfile.java @@ -0,0 +1,101 @@ +package dev.caskeleton.grpc.security; + +/** + * The transport security a deployment runs with. + * + *

The two dangerous switches — trust-all and hostname verification off — are components rather + * than something buried in a builder, because a setting that has to be named in a value is a + * setting that shows up in a diff. Both are refused outright outside local and test, and that + * refusal is a constructor failure rather than a warning: an application that starts with trust-all + * in production is an application that has no transport security while reporting that it does. + */ +public record GrpcTlsProfile( + Environment environment, + boolean tlsEnabled, + boolean mutualTls, + boolean trustAllCertificates, + boolean hostnameVerificationEnabled, + String trustBundleReference, + String keyMaterialReference) { + + /** Where a deployment runs. Determines which relaxations are available at all. */ + public enum Environment { + /** A developer machine. May run plaintext on loopback. */ + LOCAL(false), + /** An automated test. May run plaintext or a throwaway trust store. */ + TEST(false), + /** A shared development environment. Real network, so real TLS. */ + DEV(true), + /** Pre-production. */ + STAGE(true), + /** Production. */ + PROD(true); + + private final boolean tlsRequired; + + Environment(boolean tlsRequired) { + this.tlsRequired = tlsRequired; + } + + /** Whether TLS is mandatory here. */ + public boolean tlsRequired() { + return tlsRequired; + } + } + + /** Refuses every combination that would silently remove transport security. */ + public GrpcTlsProfile { + if (environment == null) { + throw new IllegalArgumentException("a TLS profile names its environment"); + } + if (environment.tlsRequired() && !tlsEnabled) { + throw new IllegalArgumentException( + "TLS is required in " + environment + "; plaintext is only available on LOCAL and TEST"); + } + if (environment.tlsRequired() && trustAllCertificates) { + throw new IllegalArgumentException( + "trust-all is refused in " + + environment + + ": accepting any certificate is indistinguishable from having no transport " + + "security while reporting that there is some"); + } + if (environment.tlsRequired() && !hostnameVerificationEnabled) { + throw new IllegalArgumentException( + "hostname verification may not be disabled in " + + environment + + "; a valid certificate for the wrong host is exactly what it exists to catch"); + } + if (tlsEnabled + && !trustAllCertificates + && (trustBundleReference == null || trustBundleReference.isBlank())) { + throw new IllegalArgumentException("TLS needs a trust bundle reference"); + } + if (mutualTls && (keyMaterialReference == null || keyMaterialReference.isBlank())) { + throw new IllegalArgumentException("mTLS needs a client key material reference"); + } + if (mutualTls && !tlsEnabled) { + throw new IllegalArgumentException("mTLS without TLS is not a thing"); + } + } + + /** Server-authenticated TLS for a deployed environment. */ + public static GrpcTlsProfile serverAuthenticated( + Environment environment, String trustBundleReference) { + return new GrpcTlsProfile(environment, true, false, false, true, trustBundleReference, null); + } + + /** Mutual TLS for a deployed environment. */ + public static GrpcTlsProfile mutual( + Environment environment, String trustBundleReference, String keyMaterialReference) { + return new GrpcTlsProfile( + environment, true, true, false, true, trustBundleReference, keyMaterialReference); + } + + /** Plaintext on loopback, available only on LOCAL and TEST. */ + public static GrpcTlsProfile plaintextLocal(Environment environment) { + if (environment.tlsRequired()) { + throw new IllegalArgumentException("plaintext is not available in " + environment); + } + return new GrpcTlsProfile(environment, false, false, false, true, null, null); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcFlowControlDecision.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcFlowControlDecision.java new file mode 100644 index 00000000..acdfe546 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcFlowControlDecision.java @@ -0,0 +1,43 @@ +package dev.caskeleton.grpc.streaming; + +/** + * Whether the next message may be produced, and what to do if not. + * + *

Separates "pause" from "terminate". A writer that is merely at its high-water mark should stop + * producing and resume when the transport is ready; one that has overflowed a bounded queue has + * already lost the race and the stream is over. Collapsing them into a boolean makes the first case + * behave like the second. + */ +public record GrpcFlowControlDecision( + Action action, int queuedMessages, long queuedBytes, String reason) { + + /** What the producer should do. */ + public enum Action { + /** Produce the next message. */ + PROCEED, + /** Stop producing until the transport reports readiness. */ + PAUSE, + /** Drop the oldest queued message and continue. */ + DROP_OLDEST, + /** End the stream; the consumer is too far behind. */ + TERMINATE + } + + /** Requires a reason and non-negative counters. */ + public GrpcFlowControlDecision { + if (action == null) { + throw new IllegalArgumentException("a flow-control decision has an action"); + } + if (queuedMessages < 0 || queuedBytes < 0L) { + throw new IllegalArgumentException("queue counters must not be negative"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("a flow-control decision explains itself"); + } + } + + /** Whether the producer may continue. */ + public boolean mayProduce() { + return action == Action.PROCEED; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcFlowControlPolicy.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcFlowControlPolicy.java new file mode 100644 index 00000000..0b9f2d9c --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcFlowControlPolicy.java @@ -0,0 +1,88 @@ +package dev.caskeleton.grpc.streaming; + +/** + * The bounds a stream's outbound queue is held to. + * + *

Both a message count and a byte count, because either alone is unbounded in the other + * dimension: a thousand-message bound with no byte bound is a memory limit set by the largest + * message anyone ever sends, and a byte bound with no count bound is unbounded queue overhead. + * + *

{@code transportReady} is an input rather than a bound. gRPC's {@code isReady} is the only + * signal that says the socket is accepting more; a writer that ignores it and relies on its own + * queue bound produces exactly as fast as it can allocate. + */ +public record GrpcFlowControlPolicy( + int maxQueuedMessages, + long maxQueuedBytes, + int highWaterMarkMessages, + GrpcSlowConsumerPolicy slowConsumerPolicy) { + + /** Refuses an incoherent bound. */ + public GrpcFlowControlPolicy { + if (maxQueuedMessages < 1) { + throw new IllegalArgumentException("a bounded queue holds at least one message"); + } + if (maxQueuedBytes < 1L) { + throw new IllegalArgumentException("a bounded queue needs a positive byte bound"); + } + if (highWaterMarkMessages < 1 || highWaterMarkMessages > maxQueuedMessages) { + throw new IllegalArgumentException( + "the high-water mark sits inside the queue bound; got " + + highWaterMarkMessages + + " of " + + maxQueuedMessages); + } + if (slowConsumerPolicy == null) { + throw new IllegalArgumentException("a flow-control policy states what a slow consumer costs"); + } + } + + /** The Stable default: terminate a consumer that overflows a 256-message, 8 MiB queue. */ + public static GrpcFlowControlPolicy stable() { + return new GrpcFlowControlPolicy( + 256, 8L * 1024L * 1024L, 192, GrpcSlowConsumerPolicy.TERMINATE); + } + + /** + * The decision for the next message. + * + * @param transportReady what gRPC's {@code isReady} says right now + */ + public GrpcFlowControlDecision decide( + int queuedMessages, long queuedBytes, long nextMessageBytes, boolean transportReady) { + if (queuedMessages < 0 || queuedBytes < 0L || nextMessageBytes < 0L) { + throw new IllegalArgumentException("queue measurements must not be negative"); + } + boolean overflowsCount = queuedMessages >= maxQueuedMessages; + boolean overflowsBytes = queuedBytes + nextMessageBytes > maxQueuedBytes; + if (overflowsCount || overflowsBytes) { + return switch (slowConsumerPolicy) { + case TERMINATE -> + new GrpcFlowControlDecision( + GrpcFlowControlDecision.Action.TERMINATE, + queuedMessages, + queuedBytes, + overflowsCount + ? "queue is at its " + maxQueuedMessages + "-message bound" + : "queue would exceed its " + maxQueuedBytes + "-byte bound"); + case DROP_OLDEST -> + new GrpcFlowControlDecision( + GrpcFlowControlDecision.Action.DROP_OLDEST, + queuedMessages, + queuedBytes, + "the profile permits loss; dropping the oldest queued message"); + }; + } + if (!transportReady || queuedMessages >= highWaterMarkMessages) { + return new GrpcFlowControlDecision( + GrpcFlowControlDecision.Action.PAUSE, + queuedMessages, + queuedBytes, + transportReady + ? "queue reached its high-water mark of " + highWaterMarkMessages + : "the transport is not ready for more"); + } + return new GrpcFlowControlDecision( + GrpcFlowControlDecision.Action.PROCEED, queuedMessages, queuedBytes, "within bounds"); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcResumeDecision.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcResumeDecision.java new file mode 100644 index 00000000..d061ac25 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcResumeDecision.java @@ -0,0 +1,57 @@ +package dev.caskeleton.grpc.streaming; + +import java.util.Optional; + +/** + * Whether a client may continue where it left off. + * + *

{@link Verdict#FULL_RESYNC_REQUIRED} is the value this type exists for. When the history + * behind a cursor is gone, the honest answer is that the position cannot be continued; pretending + * otherwise and starting from the current tail gives the client a stream with a hole in it that + * nothing will ever report. + */ +public record GrpcResumeDecision( + Verdict verdict, Optional resumeFromSequence, String reason) { + + /** What the server decided about a presented resume token. */ + public enum Verdict { + /** Continue from the token's sequence. */ + RESUME, + /** The position cannot be continued; restart from a fresh snapshot. */ + FULL_RESYNC_REQUIRED, + /** The token is invalid, expired, or belongs to a different caller or filter. */ + TOKEN_REJECTED + } + + /** Requires a sequence only on RESUME. */ + public GrpcResumeDecision { + if (verdict == null || resumeFromSequence == null) { + throw new IllegalArgumentException( + "a resume decision has a verdict and the sequence Optional"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("a resume decision explains itself"); + } + if (verdict == Verdict.RESUME && resumeFromSequence.isEmpty()) { + throw new IllegalArgumentException("a RESUME decision says where to resume from"); + } + if (verdict != Verdict.RESUME && resumeFromSequence.isPresent()) { + throw new IllegalArgumentException("only a RESUME decision carries a sequence"); + } + } + + /** Continue from {@code sequence}. */ + public static GrpcResumeDecision resume(long sequence, String reason) { + return new GrpcResumeDecision(Verdict.RESUME, Optional.of(sequence), reason); + } + + /** Start over with a fresh snapshot. */ + public static GrpcResumeDecision fullResync(String reason) { + return new GrpcResumeDecision(Verdict.FULL_RESYNC_REQUIRED, Optional.empty(), reason); + } + + /** Refuse the token. */ + public static GrpcResumeDecision reject(String reason) { + return new GrpcResumeDecision(Verdict.TOKEN_REJECTED, Optional.empty(), reason); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcResumeToken.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcResumeToken.java new file mode 100644 index 00000000..7f2a82f1 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcResumeToken.java @@ -0,0 +1,91 @@ +package dev.caskeleton.grpc.streaming; + +import java.time.Instant; + +/** + * Where a stream left off, in a form the client can hand back. + * + *

Every field is here because leaving it out breaks a real case. Without the snapshot version, a + * resume continues from a position in a view that no longer exists. Without the expiry, a token + * kept overnight resumes from a cursor whose history is long gone. Without the filter fingerprint, + * a client can resume someone else's filter from its own position and receive rows it never asked + * for and may not be allowed to see. Without the key id, the signing key cannot be rotated without + * invalidating every outstanding token at once. + */ +public record GrpcResumeToken( + int version, + String streamId, + long generation, + String snapshotVersion, + long lastSequence, + String callerFingerprint, + String filterFingerprint, + Instant expiresAt, + String signingKeyId) { + + /** The current token layout version. */ + public static final int CURRENT_VERSION = 1; + + /** Requires every field, since each one exists to close a specific hole. */ + public GrpcResumeToken { + if (version < 1) { + throw new IllegalArgumentException("a resume token carries its layout version"); + } + requireBounded(streamId, "stream id"); + requireBounded(snapshotVersion, "snapshot version"); + requireBounded(callerFingerprint, "caller fingerprint"); + requireBounded(filterFingerprint, "filter fingerprint"); + requireBounded(signingKeyId, "signing key id"); + if (generation < 1) { + throw new IllegalArgumentException("stream generations are 1-based"); + } + if (lastSequence < 0) { + throw new IllegalArgumentException("a last sequence is at or after zero"); + } + if (expiresAt == null) { + throw new IllegalArgumentException( + "a resume token expires; one without an expiry resumes from a cursor whose history is gone"); + } + } + + private static void requireBounded(String value, String what) { + if (value == null || value.isBlank() || value.length() > 128) { + throw new IllegalArgumentException(what + " must be a bounded non-blank value"); + } + if (value.indexOf('|') >= 0) { + throw new IllegalArgumentException( + what + " must not contain '|', which separates the token's fields"); + } + } + + /** Whether the token is still usable at {@code now}. */ + public boolean validAt(Instant now) { + return now != null && now.isBefore(expiresAt); + } + + /** Whether the token belongs to the caller and filter presenting it. */ + public boolean matches(String presentedCaller, String presentedFilter) { + return callerFingerprint.equals(presentedCaller) && filterFingerprint.equals(presentedFilter); + } + + /** The canonical serialization the signature is computed over. */ + public String canonicalPayload() { + return version + + "|" + + streamId + + "|" + + generation + + "|" + + snapshotVersion + + "|" + + lastSequence + + "|" + + callerFingerprint + + "|" + + filterFingerprint + + "|" + + expiresAt.toEpochMilli() + + "|" + + signingKeyId; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcResumeTokenCodec.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcResumeTokenCodec.java new file mode 100644 index 00000000..16fc951c --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcResumeTokenCodec.java @@ -0,0 +1,142 @@ +package dev.caskeleton.grpc.streaming; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Signs and verifies resume tokens. + * + *

An HMAC rather than an opaque server-side reference, because the alternative is a table of + * outstanding cursors that has to be sized, expired and replicated. Signing moves the storage to + * the client and keeps the trust on the server. + * + *

Verification is constant-time and rejects an unknown key id rather than falling back to the + * current key. A codec that retries verification with every key it holds turns key rotation into a + * window in which a token signed by a compromised key still verifies. + */ +public final class GrpcResumeTokenCodec { + + private static final String ALGORITHM = "HmacSHA256"; + private static final Pattern FIELD_SEPARATOR = Pattern.compile("\\|"); + private static final int FIELD_COUNT = 9; + + private final Map keysById; + private final String activeKeyId; + + /** + * @param keysById every key that may still verify a token, including superseded ones + * @param activeKeyId the key new tokens are signed with + */ + public GrpcResumeTokenCodec(Map keysById, String activeKeyId) { + if (keysById == null || keysById.isEmpty()) { + throw new IllegalArgumentException("a token codec needs at least one signing key"); + } + if (activeKeyId == null || !keysById.containsKey(activeKeyId)) { + throw new IllegalArgumentException("the active key id must name one of the supplied keys"); + } + Map copy = new java.util.LinkedHashMap<>(); + keysById.forEach((id, key) -> copy.put(id, key.clone())); + this.keysById = Map.copyOf(copy); + this.activeKeyId = activeKeyId; + } + + /** The key id new tokens are signed with. */ + public String activeKeyId() { + return activeKeyId; + } + + /** Encodes and signs {@code token}. */ + public String encode(GrpcResumeToken token) { + if (token == null) { + throw new IllegalArgumentException("a token is required"); + } + byte[] key = keysById.get(token.signingKeyId()); + if (key == null) { + throw new IllegalArgumentException( + "cannot sign with unknown key id '" + token.signingKeyId() + "'"); + } + String payload = token.canonicalPayload(); + String signature = Base64.getUrlEncoder().withoutPadding().encodeToString(sign(key, payload)); + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString((payload + "|" + signature).getBytes(StandardCharsets.UTF_8)); + } + + /** + * Decodes and verifies {@code encoded}. + * + * @return empty when the token is malformed, signed by an unknown key, or does not verify. The + * three are deliberately indistinguishable to a caller: telling them apart is a probing + * oracle. + */ + public Optional decode(String encoded) { + if (encoded == null || encoded.isBlank()) { + return Optional.empty(); + } + String decoded; + try { + decoded = new String(Base64.getUrlDecoder().decode(encoded), StandardCharsets.UTF_8); + } catch (IllegalArgumentException malformed) { + return Optional.empty(); + } + int lastSeparator = decoded.lastIndexOf('|'); + if (lastSeparator < 0) { + return Optional.empty(); + } + String payload = decoded.substring(0, lastSeparator); + String presentedSignature = decoded.substring(lastSeparator + 1); + + String[] fields = FIELD_SEPARATOR.split(payload, -1); + if (fields.length != FIELD_COUNT) { + return Optional.empty(); + } + byte[] key = keysById.get(fields[8]); + if (key == null) { + return Optional.empty(); + } + byte[] expected = sign(key, payload); + byte[] presented; + try { + presented = Base64.getUrlDecoder().decode(presentedSignature); + } catch (IllegalArgumentException malformed) { + return Optional.empty(); + } + if (!java.security.MessageDigest.isEqual(expected, presented)) { + return Optional.empty(); + } + try { + return Optional.of( + new GrpcResumeToken( + Integer.parseInt(fields[0]), + fields[1], + Long.parseLong(fields[2]), + fields[3], + Long.parseLong(fields[4]), + fields[5], + fields[6], + Instant.ofEpochMilli(Long.parseLong(fields[7])), + fields[8])); + } catch (RuntimeException malformed) { + return Optional.empty(); + } + } + + private static byte[] sign(byte[] key, String payload) { + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(new SecretKeySpec(key, ALGORITHM)); + return mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException | InvalidKeyException unavailable) { + throw new IllegalStateException( + ALGORITHM + " is unavailable or the key is unusable", unavailable); + } + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcSerializedStreamWriter.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcSerializedStreamWriter.java new file mode 100644 index 00000000..d46cc22f --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcSerializedStreamWriter.java @@ -0,0 +1,176 @@ +package dev.caskeleton.grpc.streaming; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.LongSupplier; + +/** + * The single writer every server stream goes through. + * + *

gRPC's {@code StreamObserver} is not thread-safe, and the failure when two producers call + * {@code onNext} concurrently is not an exception — it is interleaved bytes on the wire, which the + * client decodes as a corrupt message or, worse, as a valid one it never should have received. A + * bounded queue drained by one writer is the only shape that makes concurrent producers safe. + * + *

{@link #write} returning {@link GrpcStreamWriteResult#ACCEPTED} means the message was queued. + * It is deliberately not called {@code sent}: the transport call that follows returns as soon as + * the bytes are handed over, so no method here can honestly report delivery. + * + * @param the payload type + */ +public final class GrpcSerializedStreamWriter { + + private final GrpcStreamId streamId; + private final GrpcStreamSequence sequence; + private final GrpcFlowControlPolicy flowControl; + private final Consumer> transport; + private final LongSupplier payloadSizer; + private final Deque> queue = new ArrayDeque<>(); + + private GrpcStreamWriterState state = GrpcStreamWriterState.OPEN; + private long queuedBytes; + private int droppedMessages; + private long lastFlushedSequence; + + /** + * @param transport the single consumer that actually calls the observer. Invoked only from {@link + * #flush}, which is synchronized, so the observer sees one thread. + * @param payloadSizer the serialized size of the next payload, for the byte bound + */ + public GrpcSerializedStreamWriter( + GrpcStreamId streamId, + GrpcStreamSequence sequence, + GrpcFlowControlPolicy flowControl, + Consumer> transport, + LongSupplier payloadSizer) { + if (streamId == null + || sequence == null + || flowControl == null + || transport == null + || payloadSizer == null) { + throw new IllegalArgumentException("a stream writer needs all five collaborators"); + } + this.streamId = streamId; + this.sequence = sequence; + this.flowControl = flowControl; + this.transport = transport; + this.payloadSizer = payloadSizer; + } + + /** Queues one payload under the flow-control policy. */ + public synchronized GrpcStreamWriteResult write( + GrpcStreamEnvelope.Kind kind, + T payload, + String snapshotVersion, + String resumeToken, + boolean transportReady) { + if (!state.acceptsMessages()) { + return GrpcStreamWriteResult.REJECTED_CLOSED; + } + long nextBytes = payloadSizer.getAsLong(); + GrpcFlowControlDecision decision = + flowControl.decide(queue.size(), queuedBytes, nextBytes, transportReady); + return switch (decision.action()) { + case TERMINATE -> { + state = GrpcStreamWriterState.DRAINING; + yield GrpcStreamWriteResult.OVERFLOW_TERMINATING; + } + case DROP_OLDEST -> { + GrpcStreamEnvelope dropped = queue.pollFirst(); + if (dropped != null) { + queuedBytes = Math.max(0L, queuedBytes - nextBytes); + droppedMessages++; + } + enqueue(kind, payload, snapshotVersion, resumeToken, nextBytes); + yield GrpcStreamWriteResult.DROPPED; + } + case PAUSE, PROCEED -> { + enqueue(kind, payload, snapshotVersion, resumeToken, nextBytes); + yield GrpcStreamWriteResult.ACCEPTED; + } + }; + } + + private void enqueue( + GrpcStreamEnvelope.Kind kind, + T payload, + String snapshotVersion, + String resumeToken, + long bytes) { + queue.addLast( + GrpcStreamEnvelope.message( + streamId, sequence.next(), kind, payload, snapshotVersion, resumeToken)); + queuedBytes += bytes; + } + + /** + * Hands every queued message to the transport, in order, from this one thread. + * + * @return the sequence numbers flushed + */ + public synchronized List flush() { + java.util.List flushed = new java.util.ArrayList<>(); + while (!queue.isEmpty()) { + GrpcStreamEnvelope envelope = queue.pollFirst(); + transport.accept(envelope); + lastFlushedSequence = envelope.sequence(); + flushed.add(envelope.sequence()); + } + queuedBytes = 0L; + if (state == GrpcStreamWriterState.DRAINING) { + state = GrpcStreamWriterState.TERMINATED; + } + return List.copyOf(flushed); + } + + /** + * Sends the terminal message once. + * + * @return false when the stream was already terminated, which happens routinely when a drain and + * a completion arrive together + */ + public synchronized boolean terminate(GrpcStreamTerminationReason reason, String resumeToken) { + if (state == GrpcStreamWriterState.TERMINATED) { + return false; + } + transport.accept( + GrpcStreamEnvelope.termination(streamId, sequence.next(), reason, resumeToken)); + state = GrpcStreamWriterState.TERMINATED; + return true; + } + + /** + * Discards queued messages on cancellation, keeping the last flushed position as evidence. + * + * @return the last sequence actually handed to the transport + */ + public synchronized long discardQueued() { + queue.clear(); + queuedBytes = 0L; + state = GrpcStreamWriterState.TERMINATED; + return lastFlushedSequence; + } + + /** The writer's state. */ + public synchronized GrpcStreamWriterState state() { + return state; + } + + /** How many messages are queued. */ + public synchronized int queuedMessages() { + return queue.size(); + } + + /** How many messages a lossy profile dropped. */ + public synchronized int droppedMessages() { + return droppedMessages; + } + + /** The resume evidence for a stream that ended with messages still queued. */ + public synchronized Optional lastFlushedSequence() { + return lastFlushedSequence == 0L ? Optional.empty() : Optional.of(lastFlushedSequence); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcSlowConsumerPolicy.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcSlowConsumerPolicy.java new file mode 100644 index 00000000..4e5ad003 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcSlowConsumerPolicy.java @@ -0,0 +1,26 @@ +package dev.caskeleton.grpc.streaming; + +/** + * What to do when a consumer cannot keep up. + * + *

{@link #TERMINATE} is the default, and the alternative is worse than it sounds. Dropping + * messages silently gives the client a stream that looks healthy and is missing changes, and the + * client has no way to detect it: the sequence numbers it sees are the ones it was sent. + * Terminating is loud, and a client that reconnects and resynchronises ends up correct. + */ +public enum GrpcSlowConsumerPolicy { + /** End the stream. The client learns it fell behind and can resynchronise. */ + TERMINATE, + /** + * Drop the oldest queued messages. + * + *

Only for streams whose business meaning tolerates loss — a telemetry feed, a presence + * indicator — and never for one a client derives state from. + */ + DROP_OLDEST; + + /** Whether loss is acceptable under this policy. */ + public boolean tolerable() { + return this == DROP_OLDEST; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamAdmission.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamAdmission.java new file mode 100644 index 00000000..5b6c02fb --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamAdmission.java @@ -0,0 +1,76 @@ +package dev.caskeleton.grpc.streaming; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Bounds how many streams a server holds open at once. + * + *

Streams are not requests: they occupy a connection, a queue and a producer for their whole + * lifetime, so the number of them is a capacity number in a way that request rate is not. Without a + * bound, a client that reconnects on every error opens streams faster than the old ones close. + */ +public final class GrpcStreamAdmission { + + private final int maxConcurrentStreams; + private final int maxStreamsPerCaller; + private final AtomicInteger openStreams = new AtomicInteger(); + private final java.util.concurrent.ConcurrentMap perCaller = + new java.util.concurrent.ConcurrentHashMap<>(); + + /** Bounds both the total and the per-caller share. */ + public GrpcStreamAdmission(int maxConcurrentStreams, int maxStreamsPerCaller) { + if (maxConcurrentStreams < 1 || maxStreamsPerCaller < 1) { + throw new IllegalArgumentException("stream admission bounds must be positive"); + } + if (maxStreamsPerCaller > maxConcurrentStreams) { + throw new IllegalArgumentException( + "a per-caller bound above the total bound is not a per-caller bound"); + } + this.maxConcurrentStreams = maxConcurrentStreams; + this.maxStreamsPerCaller = maxStreamsPerCaller; + } + + /** + * Admits a stream for {@code callerFingerprint}, or refuses it. + * + * @return false when either bound is reached; the caller answers RESOURCE_EXHAUSTED + */ + public boolean tryAdmit(String callerFingerprint) { + if (callerFingerprint == null || callerFingerprint.isBlank()) { + throw new IllegalArgumentException("stream admission needs a caller fingerprint"); + } + AtomicInteger callerCount = + perCaller.computeIfAbsent(callerFingerprint, key -> new AtomicInteger()); + if (callerCount.get() >= maxStreamsPerCaller) { + return false; + } + if (openStreams.get() >= maxConcurrentStreams) { + return false; + } + callerCount.incrementAndGet(); + openStreams.incrementAndGet(); + return true; + } + + /** Releases a stream's slot. */ + public void release(String callerFingerprint) { + AtomicInteger callerCount = perCaller.get(callerFingerprint); + if (callerCount != null && callerCount.get() > 0) { + callerCount.decrementAndGet(); + } + if (openStreams.get() > 0) { + openStreams.decrementAndGet(); + } + } + + /** How many streams are open. */ + public int openStreams() { + return openStreams.get(); + } + + /** How many streams {@code callerFingerprint} holds. */ + public int openStreamsFor(String callerFingerprint) { + AtomicInteger callerCount = perCaller.get(callerFingerprint); + return callerCount == null ? 0 : callerCount.get(); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamEnvelope.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamEnvelope.java new file mode 100644 index 00000000..598de0d5 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamEnvelope.java @@ -0,0 +1,128 @@ +package dev.caskeleton.grpc.streaming; + +import java.util.Optional; + +/** + * What wraps every Stable server-stream message. + * + *

The snapshot version and the resume cursor are separate components because they answer + * different questions. The snapshot version says which consistent view the stream is reading from; + * the resume cursor says where to continue. A client that has both can tell "you are behind on the + * same snapshot" from "the snapshot moved and your position is meaningless" — which is the + * difference between resuming and resynchronising. + * + * @param the payload type + */ +public record GrpcStreamEnvelope( + GrpcStreamId streamId, + long sequence, + Kind kind, + Optional snapshotVersion, + Optional resumeToken, + Optional terminationReason, + Optional payload) { + + /** What a stream message is. */ + public enum Kind { + /** Part of the initial consistent view. */ + SNAPSHOT(true), + /** The snapshot is complete; what follows is live. */ + SNAPSHOT_COMPLETE(false), + /** A live change. */ + LIVE(true), + /** A liveness signal. Not an acknowledgement and not an ordering guarantee. */ + HEARTBEAT(false), + /** The final message. */ + TERMINATION(false); + + private final boolean carriesPayload; + + Kind(boolean carriesPayload) { + this.carriesPayload = carriesPayload; + } + + /** Whether this kind carries a business payload. */ + public boolean carriesPayload() { + return carriesPayload; + } + + /** Whether this kind ends the stream. */ + public boolean terminal() { + return this == TERMINATION; + } + } + + /** Rejects an envelope whose kind and contents disagree. */ + public GrpcStreamEnvelope { + if (streamId == null || kind == null) { + throw new IllegalArgumentException("an envelope needs a stream id and a kind"); + } + if (sequence < 1) { + throw new IllegalArgumentException("stream sequences are 1-based; got " + sequence); + } + if (snapshotVersion == null + || resumeToken == null + || terminationReason == null + || payload == null) { + throw new IllegalArgumentException("every envelope Optional must be present"); + } + if (kind.carriesPayload() && payload.isEmpty()) { + throw new IllegalArgumentException(kind + " carries a payload"); + } + if (!kind.carriesPayload() && payload.isPresent()) { + throw new IllegalArgumentException(kind + " carries no payload"); + } + if (kind.terminal() && terminationReason.isEmpty()) { + throw new IllegalArgumentException("a terminal envelope says why the stream ended"); + } + if (!kind.terminal() && terminationReason.isPresent()) { + throw new IllegalArgumentException("only a terminal envelope carries a termination reason"); + } + } + + /** A snapshot or live payload message. */ + public static GrpcStreamEnvelope message( + GrpcStreamId streamId, + long sequence, + Kind kind, + T payload, + String snapshotVersion, + String resumeToken) { + return new GrpcStreamEnvelope<>( + streamId, + sequence, + kind, + Optional.ofNullable(snapshotVersion), + Optional.ofNullable(resumeToken), + Optional.empty(), + Optional.of(payload)); + } + + /** A heartbeat. */ + public static GrpcStreamEnvelope heartbeat(GrpcStreamId streamId, long sequence) { + return new GrpcStreamEnvelope<>( + streamId, + sequence, + Kind.HEARTBEAT, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty()); + } + + /** The final message. */ + public static GrpcStreamEnvelope termination( + GrpcStreamId streamId, + long sequence, + GrpcStreamTerminationReason reason, + String resumeToken) { + return new GrpcStreamEnvelope<>( + streamId, + sequence, + Kind.TERMINATION, + Optional.empty(), + Optional.ofNullable(resumeToken), + Optional.of(reason), + Optional.empty()); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamGapDetector.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamGapDetector.java new file mode 100644 index 00000000..57834629 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamGapDetector.java @@ -0,0 +1,104 @@ +package dev.caskeleton.grpc.streaming; + +import java.time.Instant; +import java.util.Optional; + +/** + * Decides whether a presented resume token can be honoured, and catches sequence gaps and + * duplicates as messages arrive. + * + *

The handoff from snapshot to live is where both failures actually happen. A live event that + * arrives while the snapshot is still streaming is either buffered or delivered early depending on + * the profile, and getting it wrong produces a duplicate on one side and a gap on the other — both + * of which look like a working stream from the outside. + */ +public final class GrpcStreamGapDetector { + + private final long retainedHistoryFrom; + private long lastObservedSequence; + + /** + * @param retainedHistoryFrom the oldest sequence the server can still replay. A token pointing + * before it cannot be resumed, however valid its signature. + */ + public GrpcStreamGapDetector(long retainedHistoryFrom) { + if (retainedHistoryFrom < 0) { + throw new IllegalArgumentException("retained history starts at or after zero"); + } + this.retainedHistoryFrom = retainedHistoryFrom; + } + + /** + * Whether {@code token} may be honoured. + * + * @param currentSnapshotVersion the snapshot the server would serve now + */ + public GrpcResumeDecision evaluate( + GrpcResumeToken token, + String presentedCaller, + String presentedFilter, + String currentSnapshotVersion, + Instant now) { + if (token == null) { + return GrpcResumeDecision.reject("no resume token was presented"); + } + if (!token.validAt(now)) { + return GrpcResumeDecision.reject("the resume token has expired"); + } + if (!token.matches(presentedCaller, presentedFilter)) { + return GrpcResumeDecision.reject( + "the resume token was issued for a different caller or filter"); + } + if (!token.snapshotVersion().equals(currentSnapshotVersion)) { + return GrpcResumeDecision.fullResync( + "the snapshot moved from " + + token.snapshotVersion() + + " to " + + currentSnapshotVersion + + ", so the token's position no longer means anything"); + } + if (token.lastSequence() < retainedHistoryFrom) { + return GrpcResumeDecision.fullResync( + "history before sequence " + + retainedHistoryFrom + + " is gone; resuming from " + + token.lastSequence() + + " would silently skip what is missing"); + } + return GrpcResumeDecision.resume( + token.lastSequence(), "the token is valid and its position is still replayable"); + } + + /** What a newly observed sequence means. */ + public enum Observation { + /** Exactly the next expected sequence. */ + IN_ORDER, + /** One or more sequences were skipped. */ + GAP, + /** A sequence at or before the last one seen. */ + DUPLICATE + } + + /** + * Records an observed sequence and reports what it means. + * + *

A duplicate does not advance the position, so a resume that redelivers a message the client + * already applied stays consistent. + */ + public Observation observe(long sequence) { + if (sequence < 1) { + throw new IllegalArgumentException("stream sequences are 1-based"); + } + if (sequence <= lastObservedSequence) { + return Observation.DUPLICATE; + } + boolean gap = lastObservedSequence != 0L && sequence != lastObservedSequence + 1; + lastObservedSequence = sequence; + return gap ? Observation.GAP : Observation.IN_ORDER; + } + + /** The last sequence observed, if any. */ + public Optional lastObservedSequence() { + return lastObservedSequence == 0L ? Optional.empty() : Optional.of(lastObservedSequence); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamHeartbeat.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamHeartbeat.java new file mode 100644 index 00000000..8a622154 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamHeartbeat.java @@ -0,0 +1,62 @@ +package dev.caskeleton.grpc.streaming; + +import java.time.Duration; +import java.time.Instant; + +/** + * A liveness signal, and nothing more. + * + *

Named and documented this way because heartbeats get reused for two things they cannot do. A + * heartbeat is not an application acknowledgement — it says the connection is alive, not that the + * consumer applied anything — and it is not an ordering guarantee, because it carries no position + * in the business sequence. The Stable envelope gives it a sequence only so that a gap detector can + * see a continuous stream. + */ +public final class GrpcStreamHeartbeat { + + private final Duration interval; + private Instant lastActivity; + + /** Starts the clock at {@code startedAt}. */ + public GrpcStreamHeartbeat(Duration interval, Instant startedAt) { + if (interval == null || interval.isZero() || interval.isNegative()) { + throw new IllegalArgumentException("a heartbeat interval must be positive"); + } + if (startedAt == null) { + throw new IllegalArgumentException("a heartbeat needs a starting moment"); + } + this.interval = interval; + this.lastActivity = startedAt; + } + + /** Records that a real message went out, which postpones the next heartbeat. */ + public void recordActivity(Instant at) { + if (at == null) { + throw new IllegalArgumentException("activity needs a moment"); + } + if (at.isAfter(lastActivity)) { + lastActivity = at; + } + } + + /** Whether a heartbeat is due at {@code now}. */ + public boolean due(Instant now) { + if (now == null) { + throw new IllegalArgumentException("a heartbeat check needs a moment"); + } + return !now.isBefore(lastActivity.plus(interval)); + } + + /** Whether nothing has been sent for {@code idleTimeout}. */ + public boolean idleFor(Instant now, Duration idleTimeout) { + if (now == null || idleTimeout == null) { + throw new IllegalArgumentException("an idle check needs a moment and a timeout"); + } + return !now.isBefore(lastActivity.plus(idleTimeout)); + } + + /** When something last went out. */ + public Instant lastActivity() { + return lastActivity; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamId.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamId.java new file mode 100644 index 00000000..6fc29b9a --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamId.java @@ -0,0 +1,41 @@ +package dev.caskeleton.grpc.streaming; + +import java.util.UUID; + +/** + * A stream's opaque identity, plus the generation that makes its sequence meaningful. + * + *

The generation is what a resumed stream shares with the one it continues, and a restarted + * stream does not. Without it, "sequence 41" is ambiguous the moment a stream is re-established, + * and a gap detector comparing sequences across generations reports a gap that is really a restart. + * + *

Never a metric label. The id is per-stream and therefore unbounded, which is the definition of + * a cardinality problem. + */ +public record GrpcStreamId(String value, long generation) { + + /** Requires a bounded id and a positive generation. */ + public GrpcStreamId { + if (value == null || value.isBlank() || value.length() > 64) { + throw new IllegalArgumentException("a stream id is a bounded non-blank identifier"); + } + if (generation < 1) { + throw new IllegalArgumentException("stream generations are 1-based; got " + generation); + } + } + + /** A fresh stream at generation 1. */ + public static GrpcStreamId newStream() { + return new GrpcStreamId(UUID.randomUUID().toString(), 1L); + } + + /** The same stream, one generation on, after a resume. */ + public GrpcStreamId nextGeneration() { + return new GrpcStreamId(value, generation + 1); + } + + /** Whether {@code other} is the same logical stream, in any generation. */ + public boolean sameStreamAs(GrpcStreamId other) { + return other != null && value.equals(other.value()); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamLifecycleCoordinator.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamLifecycleCoordinator.java new file mode 100644 index 00000000..8d5ce28a --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamLifecycleCoordinator.java @@ -0,0 +1,82 @@ +package dev.caskeleton.grpc.streaming; + +import java.time.Instant; +import java.util.Optional; + +/** + * Decides, at each tick, whether a stream keeps running and how it ends if not. + * + *

The checks run in a fixed order, and the order is the contract: credential expiry first, + * because continuing to serve a caller whose authority is gone is a security failure rather than a + * lifetime one; then drain, because a server that has been told to stop should stop before its own + * timers fire; then max duration and idle. Reordering them changes which reason the client is told, + * and the reason is what the client acts on. + */ +public final class GrpcStreamLifecycleCoordinator { + + private final GrpcStreamLifetimePolicy policy; + private final GrpcStreamHeartbeat heartbeat; + private final Instant startedAt; + private boolean drainSignalled; + + /** Starts a stream's lifecycle at {@code startedAt}. */ + public GrpcStreamLifecycleCoordinator(GrpcStreamLifetimePolicy policy, Instant startedAt) { + if (policy == null || startedAt == null) { + throw new IllegalArgumentException( + "a lifecycle coordinator needs a policy and a start moment"); + } + this.policy = policy; + this.startedAt = startedAt; + this.heartbeat = new GrpcStreamHeartbeat(policy.heartbeatInterval(), startedAt); + } + + /** Records that a message went out. */ + public void recordActivity(Instant at) { + heartbeat.recordActivity(at); + } + + /** Tells the stream the server is shutting down. */ + public void signalDrain() { + drainSignalled = true; + } + + /** Whether a heartbeat should be emitted now. */ + public boolean heartbeatDue(Instant now) { + return heartbeat.due(now); + } + + /** + * Why the stream should end now, or empty when it should keep running. + * + * @param credentialExpiry when the caller's authority runs out, or null when it does not + */ + public Optional terminationDue( + Instant now, Instant credentialExpiry) { + if (now == null) { + throw new IllegalArgumentException("a lifecycle check needs a moment"); + } + if (credentialExpiry != null && !now.isBefore(credentialExpiry)) { + return Optional.of(GrpcStreamTerminationReason.CREDENTIAL_EXPIRED); + } + if (drainSignalled) { + return Optional.of(GrpcStreamTerminationReason.SERVER_DRAIN); + } + if (!now.isBefore(startedAt.plus(policy.maxDuration()))) { + return Optional.of(GrpcStreamTerminationReason.MAX_DURATION); + } + if (heartbeat.idleFor(now, policy.idleTimeout())) { + return Optional.of(GrpcStreamTerminationReason.IDLE_TIMEOUT); + } + return Optional.empty(); + } + + /** Whether the stream failed to produce its first message inside the setup deadline. */ + public boolean setupDeadlineExceeded(Instant now, boolean firstMessageSent) { + return !firstMessageSent && !now.isBefore(startedAt.plus(policy.setupDeadline())); + } + + /** Whether a client should reconnect after ending for {@code reason}. */ + public boolean reconnectCandidate(GrpcStreamTerminationReason reason) { + return policy.reconnectCandidate(reason); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamLifetimePolicy.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamLifetimePolicy.java new file mode 100644 index 00000000..13fb637d --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamLifetimePolicy.java @@ -0,0 +1,71 @@ +package dev.caskeleton.grpc.streaming; + +import java.time.Duration; + +/** + * The four clocks a long-lived stream runs against, kept apart because they mean different things. + * + *

Setup deadline bounds how long the first message may take, which is the only part of a stream + * a unary-style deadline could ever have covered. Idle timeout notices a stream nothing is flowing + * through. Max duration ends one that has been up long enough that its connection, its credentials + * and its pod assignment are all stale. Heartbeat interval keeps intermediaries from calling an + * idle stream dead. + * + *

Collapsing any pair of them produces a familiar bug: an idle timeout used as a max duration + * kills healthy busy streams, and a max duration used as an idle timeout leaves dead ones open for + * hours. + */ +public record GrpcStreamLifetimePolicy( + Duration setupDeadline, + Duration idleTimeout, + Duration maxDuration, + Duration heartbeatInterval, + boolean reconnectExpected) { + + /** Refuses a combination whose clocks would fight each other. */ + public GrpcStreamLifetimePolicy { + requirePositive(setupDeadline, "setup deadline"); + requirePositive(idleTimeout, "idle timeout"); + requirePositive(maxDuration, "max duration"); + requirePositive(heartbeatInterval, "heartbeat interval"); + if (heartbeatInterval.compareTo(idleTimeout) >= 0) { + throw new IllegalArgumentException( + "a heartbeat interval at or above the idle timeout lets a healthy stream be declared idle"); + } + if (idleTimeout.compareTo(maxDuration) > 0) { + throw new IllegalArgumentException( + "an idle timeout longer than the max duration can never fire"); + } + if (setupDeadline.compareTo(maxDuration) > 0) { + throw new IllegalArgumentException( + "a setup deadline longer than the max duration outlives the stream it sets up"); + } + } + + private static void requirePositive(Duration value, String what) { + if (value == null || value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(what + " must be positive"); + } + } + + /** The Stable default for a long-lived subscription. */ + public static GrpcStreamLifetimePolicy longLived() { + return new GrpcStreamLifetimePolicy( + Duration.ofSeconds(10), + Duration.ofMinutes(2), + Duration.ofHours(1), + Duration.ofSeconds(30), + true); + } + + /** + * Whether a termination for {@code reason} makes this stream a candidate for automatic reconnect. + * + *

Both halves are required: the reason has to be one a reconnect helps with, and the profile + * has to expect reconnects. A client that reconnects a stream the server deliberately closed is a + * client in a loop. + */ + public boolean reconnectCandidate(GrpcStreamTerminationReason reason) { + return reconnectExpected && reason != null && reason.reconnectExpected(); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamProfile.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamProfile.java new file mode 100644 index 00000000..f999c26c --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamProfile.java @@ -0,0 +1,47 @@ +package dev.caskeleton.grpc.streaming; + +/** + * How one streaming method behaves before any message is sent. + * + *

{@code liveEventsBeforeSnapshotComplete} is the setting worth arguing about. Delivering live + * events while the snapshot is still being sent gives the client fresher data and an ordering it + * cannot reconstruct: it may see the update to a row before the row itself. Buffering them is + * correct and costs memory bounded by the snapshot duration. Neither is right for every method, + * which is why this is a per-method decision rather than a platform default. + */ +public record GrpcStreamProfile( + boolean snapshotFirst, + boolean liveEventsBeforeSnapshotComplete, + boolean resumable, + int maxBufferedMessages, + long maxBufferedBytes) { + + /** Refuses an incoherent profile. */ + public GrpcStreamProfile { + if (maxBufferedMessages < 1) { + throw new IllegalArgumentException("a stream buffers at least one message"); + } + if (maxBufferedBytes < 1L) { + throw new IllegalArgumentException("a stream needs a positive byte bound"); + } + if (liveEventsBeforeSnapshotComplete && !snapshotFirst) { + throw new IllegalArgumentException( + "a profile without a snapshot cannot describe what happens before the snapshot completes"); + } + } + + /** + * The Stable default: snapshot first, live events buffered until it completes, resumable. + * + *

Buffering is the default because the alternative delivers an update to a row before the row, + * and a client that has to tolerate that is a client doing the platform's ordering work. + */ + public static GrpcStreamProfile snapshotThenLive() { + return new GrpcStreamProfile(true, false, true, 256, 8L * 1024L * 1024L); + } + + /** A live-only stream with no snapshot phase. */ + public static GrpcStreamProfile liveOnly() { + return new GrpcStreamProfile(false, false, true, 256, 8L * 1024L * 1024L); + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamSequence.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamSequence.java new file mode 100644 index 00000000..7c4f10fd --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamSequence.java @@ -0,0 +1,48 @@ +package dev.caskeleton.grpc.streaming; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * A monotonic counter scoped to one stream generation. + * + *

Scoped, not global. A counter shared across generations produces sequence numbers a resumed + * client cannot reason about; one reset per stream but not per generation makes a resume look like + * a replay from zero. + */ +public final class GrpcStreamSequence { + + private final GrpcStreamId streamId; + private final AtomicLong next; + + /** Starts at {@code startExclusive + 1}, which is how a resume continues rather than restarts. */ + public GrpcStreamSequence(GrpcStreamId streamId, long startExclusive) { + if (streamId == null) { + throw new IllegalArgumentException("a sequence belongs to a stream"); + } + if (startExclusive < 0) { + throw new IllegalArgumentException("a sequence starts at or after zero"); + } + this.streamId = streamId; + this.next = new AtomicLong(startExclusive + 1); + } + + /** A sequence for a fresh stream, starting at 1. */ + public static GrpcStreamSequence forNewStream(GrpcStreamId streamId) { + return new GrpcStreamSequence(streamId, 0L); + } + + /** The next sequence number. */ + public long next() { + return next.getAndIncrement(); + } + + /** The last number handed out, or 0 when none has been. */ + public long lastIssued() { + return next.get() - 1; + } + + /** The stream this sequence belongs to. */ + public GrpcStreamId streamId() { + return streamId; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamTerminationReason.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamTerminationReason.java new file mode 100644 index 00000000..fc01567d --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamTerminationReason.java @@ -0,0 +1,51 @@ +package dev.caskeleton.grpc.streaming; + +/** + * Why a stream ended, told to the client rather than inferred by it. + * + *

The reason decides what the client does next, and no status code carries it. A stream closed + * by an idle timeout should be reopened; one closed because the consumer could not keep up should + * not be reopened at the same rate; one closed by a drain should be reopened somewhere else; one + * closed because history was lost must resynchronise rather than resume. + */ +public enum GrpcStreamTerminationReason { + /** The stream delivered everything it had. */ + COMPLETED(false, false), + /** The client cancelled. */ + CLIENT_CANCELLED(false, false), + /** Nothing was sent for the idle timeout. */ + IDLE_TIMEOUT(true, true), + /** The stream reached its maximum duration. */ + MAX_DURATION(true, true), + /** The server is draining. Reconnect, probably to a different instance. */ + SERVER_DRAIN(true, true), + /** The consumer fell behind its bounded queue. */ + SLOW_CONSUMER(true, false), + /** The caller's credential expired or was revoked. */ + CREDENTIAL_EXPIRED(false, false), + /** History is gone; the position cannot be continued. */ + FULL_RESYNC_REQUIRED(true, false); + + private final boolean reconnectExpected; + private final boolean resumable; + + GrpcStreamTerminationReason(boolean reconnectExpected, boolean resumable) { + this.reconnectExpected = reconnectExpected; + this.resumable = resumable; + } + + /** Whether a well-behaved client reconnects after this. */ + public boolean reconnectExpected() { + return reconnectExpected; + } + + /** + * Whether a reconnect may continue from the last cursor. + * + *

False for {@link #SLOW_CONSUMER} on purpose: the messages that overflowed the queue are + * gone, so continuing from the last delivered sequence would silently skip them. + */ + public boolean resumable() { + return resumable; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamWriteResult.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamWriteResult.java new file mode 100644 index 00000000..802acdbc --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamWriteResult.java @@ -0,0 +1,25 @@ +package dev.caskeleton.grpc.streaming; + +/** + * What happened to one attempt to write a message. + * + *

{@link #ACCEPTED} means the writer queued it, and deliberately does not mean the client + * received it. The gRPC {@code onNext} call returns as soon as the message is handed to the + * transport, and treating that as delivery is the single most common way a stream is believed to be + * reliable when it is not. + */ +public enum GrpcStreamWriteResult { + /** Queued for the single writer. Not a delivery guarantee. */ + ACCEPTED, + /** The bounded queue is full and the profile drops rather than terminates. */ + DROPPED, + /** The queue overflowed and the stream is being terminated. */ + OVERFLOW_TERMINATING, + /** The writer is draining or terminated and takes nothing more. */ + REJECTED_CLOSED; + + /** Whether the message entered the queue. */ + public boolean queued() { + return this == ACCEPTED; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamWriterState.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamWriterState.java new file mode 100644 index 00000000..1f7318b5 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/streaming/GrpcStreamWriterState.java @@ -0,0 +1,22 @@ +package dev.caskeleton.grpc.streaming; + +/** + * Where a serialized stream writer is in its life. + * + *

{@link #TERMINATED} exists so that "already finished" is a state rather than a race. gRPC + * throws when a call is completed twice, and a writer fed by several producers will be asked to + * terminate twice as a matter of course, when a drain and a completion arrive together. + */ +public enum GrpcStreamWriterState { + /** Accepting messages. */ + OPEN, + /** No longer accepting messages; queued ones are still being flushed. */ + DRAINING, + /** The terminal signal has been sent. Nothing more will be. */ + TERMINATED; + + /** Whether new messages are accepted. */ + public boolean acceptsMessages() { + return this == OPEN; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/validation/GrpcTransportValidator.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/validation/GrpcTransportValidator.java new file mode 100644 index 00000000..00bebff1 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/validation/GrpcTransportValidator.java @@ -0,0 +1,134 @@ +package dev.caskeleton.grpc.validation; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; + +/** + * Transport-shape validation for one request message, and nothing else. + * + *

The API is what enforces the boundary. A rule is a {@link Predicate} over the message and + * takes no other argument, so there is nowhere to pass a repository, a security context or a clock: + * a rule physically cannot ask the database whether the id exists, or whether this caller may write + * it. Those are use-case decisions, and moving them here would put business rules in a filter — the + * fourth hard-stop in this repository's prime directive. + * + * @param the request message type + */ +public final class GrpcTransportValidator { + + /** + * The constraint shapes a transport validator may express. + * + *

Every one of them is decidable from the message alone. There is deliberately no {@code + * EXISTS}, {@code PERMITTED} or {@code STATE} kind. + */ + public enum Kind { + /** String or bytes length bounds. */ + LENGTH, + /** Numeric or temporal range bounds. */ + RANGE, + /** Repeated-field or map cardinality bounds. */ + COLLECTION_COUNT, + /** Syntactic format, e.g. a UUID or an RFC 3339 timestamp. */ + FORMAT, + /** A relationship between two fields of the same message. */ + CROSS_FIELD + } + + /** One registered constraint. */ + public record Rule(String fieldPath, Kind kind, String reason, Predicate satisfiedBy) { + /** Rejects a malformed rule at registration, which is startup rather than first request. */ + public Rule { + if (fieldPath == null || fieldPath.isBlank()) { + throw new IllegalArgumentException("a validation rule names the field it constrains"); + } + if (kind == null) { + throw new IllegalArgumentException("a validation rule declares its constraint kind"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("a validation rule carries a stable reason constant"); + } + if (satisfiedBy == null) { + throw new IllegalArgumentException("a validation rule needs a predicate"); + } + } + } + + private final List> rules; + + private GrpcTransportValidator(List> rules) { + this.rules = List.copyOf(rules); + } + + /** A builder for the given message type. */ + public static Builder builder() { + return new Builder<>(); + } + + /** + * Every constraint the message breaks, in registration order. + * + * @return an empty list when the message is well formed + */ + public List validate(T message) { + if (message == null) { + return List.of(GrpcValidationViolation.of("*", "MESSAGE_REQUIRED")); + } + List violations = new ArrayList<>(); + for (Rule rule : rules) { + if (!rule.satisfiedBy().test(message)) { + violations.add(GrpcValidationViolation.of(rule.fieldPath(), rule.reason())); + } + } + return List.copyOf(violations); + } + + /** How many constraints are registered. */ + public int ruleCount() { + return rules.size(); + } + + /** The constraint kinds in use. */ + public Set kinds() { + Set kinds = new LinkedHashSet<>(); + rules.forEach(rule -> kinds.add(rule.kind())); + return Set.copyOf(kinds); + } + + /** Accumulates rules, refusing malformed ones as they are added. */ + public static final class Builder { + + private final List> rules = new ArrayList<>(); + private final Set seen = new LinkedHashSet<>(); + + private Builder() {} + + /** Registers one constraint. */ + public Builder rule(String fieldPath, Kind kind, String reason, Predicate satisfiedBy) { + Rule rule = new Rule<>(fieldPath, kind, reason, satisfiedBy); + if (!seen.add(fieldPath + "|" + reason)) { + throw new IllegalArgumentException( + "duplicate validation rule '" + reason + "' on field '" + fieldPath + "'"); + } + rules.add(rule); + return this; + } + + /** + * Builds the validator. + * + * @throws IllegalStateException when nothing was registered; an empty validator passes every + * message and reads, at the call site, exactly like one that works + */ + public GrpcTransportValidator build() { + if (rules.isEmpty()) { + throw new IllegalStateException( + "a transport validator with no rules accepts everything while looking like validation"); + } + return new GrpcTransportValidator<>(rules); + } + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/validation/GrpcValidationViolation.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/validation/GrpcValidationViolation.java new file mode 100644 index 00000000..a36f4830 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/validation/GrpcValidationViolation.java @@ -0,0 +1,35 @@ +package dev.caskeleton.grpc.validation; + +/** + * One transport constraint a request broke. + * + *

There is no component for the rejected value, and that absence is the design. A validation + * error is the single most likely place for a password, a token or a national id to end up in a log + * line, because the natural way to explain a length failure is to show what was too long. + */ +public record GrpcValidationViolation(String fieldPath, String reason, String description) { + + /** Requires a field path and a stable machine-readable reason. */ + public GrpcValidationViolation { + if (fieldPath == null || fieldPath.isBlank()) { + throw new IllegalArgumentException("a violation names the field path it is about"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException( + "a violation carries a stable reason constant; clients branch on it, not on prose"); + } + if (description == null) { + description = ""; + } + } + + /** A violation with no prose, only a field and a reason. */ + public static GrpcValidationViolation of(String fieldPath, String reason) { + return new GrpcValidationViolation(fieldPath, reason, ""); + } + + /** {@code field: REASON}, the form that is safe to log. */ + public String describe() { + return fieldPath + ": " + reason; + } +} diff --git a/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/validation/ProtovalidateGrpcInterceptor.java b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/validation/ProtovalidateGrpcInterceptor.java new file mode 100644 index 00000000..88d7e307 --- /dev/null +++ b/src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/validation/ProtovalidateGrpcInterceptor.java @@ -0,0 +1,115 @@ +package dev.caskeleton.grpc.validation; + +import io.grpc.ForwardingServerCallListener; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.Status; +import java.util.List; +import java.util.Map; + +/** + * Rejects a malformed request before it reaches a use case. + * + *

Placed after authentication and before the service adapter in the Stable interceptor order, so + * an unauthenticated caller cannot use validation messages to probe the schema, and a use case + * never has to re-check a length it was promised. + * + *

The violation summary goes into the response trailers, not into the status description, and it + * carries only field paths and reason constants. The rejected values never leave the process — + * which is the reason {@link GrpcValidationViolation} has nowhere to put them. + */ +public final class ProtovalidateGrpcInterceptor implements ServerInterceptor { + + /** Trailer carrying the violated field paths and reasons, comma-separated. */ + public static final Metadata.Key VIOLATIONS_KEY = + Metadata.Key.of("validation-violations", Metadata.ASCII_STRING_MARSHALLER); + + private final Map> validatorsByFullMethodName; + + /** + * Binds validators to full method names. + * + * @param validatorsByFullMethodName keyed by {@code package.Service/Method}; a method with no + * entry is passed through untouched rather than rejected, because "this method has no + * transport constraints" is a legitimate state + */ + public ProtovalidateGrpcInterceptor( + Map> validatorsByFullMethodName) { + if (validatorsByFullMethodName == null) { + throw new IllegalArgumentException("the validator map must not be null"); + } + this.validatorsByFullMethodName = Map.copyOf(validatorsByFullMethodName); + } + + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + GrpcTransportValidator validator = + validatorsByFullMethodName.get(call.getMethodDescriptor().getFullMethodName()); + if (validator == null) { + return next.startCall(call, headers); + } + return new ValidatingListener<>(next.startCall(call, headers), call, validator); + } + + /** Applies the validator to each inbound message and closes the call on the first bad one. */ + private static final class ValidatingListener + extends ForwardingServerCallListener.SimpleForwardingServerCallListener { + + private final ServerCall call; + private final GrpcTransportValidator validator; + private boolean closed; + + private ValidatingListener( + ServerCall.Listener delegate, + ServerCall call, + GrpcTransportValidator validator) { + super(delegate); + this.call = call; + this.validator = validator; + } + + @Override + public void onMessage(Q message) { + if (closed) { + return; + } + List violations = applyTo(message); + if (violations.isEmpty()) { + super.onMessage(message); + return; + } + closed = true; + Metadata trailers = new Metadata(); + trailers.put( + VIOLATIONS_KEY, + violations.stream() + .map(GrpcValidationViolation::describe) + .reduce((left, right) -> left + "," + right) + .orElse("")); + // A constant description. The per-field detail is in the trailers, where a client parses it; + // putting it in the description would make the message string the contract, which is the one + // thing the Stable error model refuses. + call.close( + Status.INVALID_ARGUMENT.withDescription("request failed transport validation"), trailers); + } + + @SuppressWarnings("unchecked") + private List applyTo(Q message) { + // The map is keyed by full method name and a method has exactly one request type, so the + // validator registered for this method is a validator for this message. The cast is checked + // by that registration, not by the type system. + return ((GrpcTransportValidator) validator).validate(message); + } + + @Override + public void onHalfClose() { + if (closed) { + return; + } + super.onHalfClose(); + } + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/context/GrpcContextBinderTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/context/GrpcContextBinderTest.java new file mode 100644 index 00000000..27c150bb --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/context/GrpcContextBinderTest.java @@ -0,0 +1,156 @@ +package dev.caskeleton.grpc.context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.deadline.GrpcCancellationToken; +import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcContextBinderTest { + + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + + private static GrpcContextSnapshot snapshot(Instant credentialExpiry) { + GrpcRequestContext request = + GrpcRequestContext.create( + GET, + RpcType.UNARY, + GrpcClientIdentity.fromVerifiedAuthentication("actor-1", "tenant-1", "jwt-issuer"), + GrpcDeadlineBudget.forEntryPoint( + Duration.ofSeconds(1), GrpcDeadlineProfile.of(Duration.ofSeconds(2))), + new GrpcCancellationToken(), + Map.of(), + Set.of(), + GrpcMetadataBudget.standard(), + "trace-1"); + return GrpcContextSnapshot.of(request, credentialExpiry); + } + + @Test + @DisplayName("a snapshot carries identity, deadline and credential expiry, but no credential") + void aSnapshotCarriesNoCredential() { + GrpcContextSnapshot snapshot = snapshot(Instant.parse("2026-08-30T11:00:00Z")); + + assertThat(snapshot.identity().actorId()).isEqualTo("actor-1"); + assertThat(snapshot.traceId()).contains("trace-1"); + assertThat(snapshot.credentialExpiry()).isPresent(); + assertThat(GrpcContextSnapshot.class.getRecordComponents()) + .extracting(java.lang.reflect.RecordComponent::getName) + .doesNotContain("credential", "token", "authorization"); + } + + @Test + @DisplayName("credential validity is asked per unit of work, not once at the start") + void credentialValidityIsCheckedPerUnitOfWork() { + Instant expiry = Instant.parse("2026-08-30T11:00:00Z"); + GrpcContextSnapshot snapshot = snapshot(expiry); + + assertThat(snapshot.credentialValidAt(expiry.minusSeconds(1))).isTrue(); + assertThat(snapshot.credentialValidAt(expiry)).isFalse(); + assertThat(snapshot.live(expiry)).isFalse(); + assertThat(snapshot(null).credentialValidAt(expiry)).isTrue(); + } + + @Test + @DisplayName("bound context is visible inside the task and gone afterwards") + void contextIsBoundAndThenCleared() { + GrpcContextBinder binder = new GrpcContextBinder(GrpcContextPropagationPolicy.stable()); + GrpcContextSnapshot snapshot = snapshot(null); + AtomicReference> seen = new AtomicReference<>(); + + binder.runWith(snapshot, () -> seen.set(binder.current())); + + assertThat(seen.get()).contains(snapshot); + assertThat(binder.current()).isEmpty(); + } + + @Test + @DisplayName("work with no context fails closed rather than running as nobody") + void contextlessWorkFailsClosed() { + GrpcContextBinder binder = new GrpcContextBinder(GrpcContextPropagationPolicy.stable()); + + assertThatThrownBy(binder::requireCurrent) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("attributed to nobody"); + assertThatThrownBy(() -> binder.wrap(() -> {})) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("no call context to carry"); + } + + @Test + @DisplayName("background work may run without a context, and still clears afterwards") + void backgroundWorkMayRunWithoutContext() { + GrpcContextBinder binder = new GrpcContextBinder(GrpcContextPropagationPolicy.backgroundWork()); + + Runnable wrapped = binder.wrap(() -> {}); + + assertThat(wrapped).isNotNull(); + assertThat(binder.policy().clearAfterTask()).isTrue(); + } + + @Test + @DisplayName("a policy that would leak context between pooled tasks is refused") + void leakingPolicyIsRefused() { + assertThatThrownBy(() -> new GrpcContextPropagationPolicy(true, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("attributes one tenant's work to another"); + } + + @Test + @DisplayName("a wrapped executor carries the submitting thread's context to another thread") + void contextCrossesAThreadBoundary() throws Exception { + GrpcContextBinder binder = new GrpcContextBinder(GrpcContextPropagationPolicy.stable()); + GrpcContextSnapshot snapshot = snapshot(null); + ExecutorService pool = Executors.newSingleThreadExecutor(); + AtomicReference observedActor = new AtomicReference<>(); + AtomicReference> afterTask = new AtomicReference<>(); + + try { + binder.runWith( + snapshot, + () -> + binder + .wrap(pool) + .execute(() -> observedActor.set(binder.requireCurrent().identity().actorId()))); + pool.shutdown(); + assertThat(pool.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + + ExecutorService second = Executors.newSingleThreadExecutor(); + second.execute(() -> afterTask.set(binder.current())); + second.shutdown(); + assertThat(second.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } finally { + pool.shutdownNow(); + } + + assertThat(observedActor).hasValue("actor-1"); + assertThat(afterTask.get()).isEmpty(); + } + + @Test + @DisplayName("callWith returns the task result and unbinds afterwards") + void callWithReturnsAndUnbinds() throws Exception { + GrpcContextBinder binder = new GrpcContextBinder(GrpcContextPropagationPolicy.stable()); + + String actor = + binder.callWith(snapshot(null), () -> binder.requireCurrent().identity().actorId()); + + assertThat(actor).isEqualTo("actor-1"); + assertThat(binder.current()).isEmpty(); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/deadline/GrpcCancellationCoordinatorTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/deadline/GrpcCancellationCoordinatorTest.java new file mode 100644 index 00000000..d8a98858 --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/deadline/GrpcCancellationCoordinatorTest.java @@ -0,0 +1,118 @@ +package dev.caskeleton.grpc.deadline; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcCancellationCoordinatorTest { + + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + + /** + * Records the reason it was cancelled with, so ordering and reason survive into the assertion. + */ + private static final class RecordingOperation implements GrpcCancellableOperation { + private final String name; + private final List cancellations = new ArrayList<>(); + + private RecordingOperation(String name) { + this.name = name; + } + + @Override + public String name() { + return name; + } + + @Override + public void cancel(GrpcCancellationReason reason) { + cancellations.add(reason); + } + } + + @Test + @DisplayName("cancellation reaches every registered operation, once, in registration order") + void cancellationReachesEveryRegisteredOperation() { + GrpcCancellationCoordinator coordinator = + new GrpcCancellationCoordinator(new GrpcCancellationToken()); + RecordingOperation query = new RecordingOperation("documents-query"); + RecordingOperation writer = new RecordingOperation("stream-writer"); + coordinator.register(query); + coordinator.register(writer); + + List cancelled = coordinator.cancel(GrpcCancellationReason.CLIENT_CANCELLED, NOW); + + assertThat(cancelled).containsExactly("documents-query", "stream-writer"); + assertThat(query.cancellations).containsExactly(GrpcCancellationReason.CLIENT_CANCELLED); + assertThat(writer.cancellations).containsExactly(GrpcCancellationReason.CLIENT_CANCELLED); + } + + @Test + @DisplayName("a second cancellation is a no-op and keeps the first reason") + void cancellationIsIdempotentAndKeepsTheFirstReason() { + GrpcCancellationCoordinator coordinator = + new GrpcCancellationCoordinator(new GrpcCancellationToken()); + RecordingOperation query = new RecordingOperation("documents-query"); + coordinator.register(query); + + coordinator.cancel(GrpcCancellationReason.CLIENT_CANCELLED, NOW); + List second = + coordinator.cancel(GrpcCancellationReason.SERVER_DRAIN, NOW.plusSeconds(1)); + + assertThat(second).isEmpty(); + assertThat(query.cancellations).hasSize(1); + assertThat(coordinator.reason()).contains(GrpcCancellationReason.CLIENT_CANCELLED); + } + + @Test + @DisplayName("no new side effect may start after cancellation") + void noNewSideEffectAfterCancellation() { + GrpcCancellationCoordinator coordinator = + new GrpcCancellationCoordinator(new GrpcCancellationToken()); + coordinator.cancel(GrpcCancellationReason.DEADLINE_EXCEEDED, NOW); + + assertThatThrownBy(() -> coordinator.register(new RecordingOperation("charge-card"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("charge-card") + .hasMessageContaining("deadline-exceeded"); + assertThatThrownBy(() -> coordinator.requireMayStartSideEffect("charge-card")) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("cancellation after the commit boundary does not report the business effect aborted") + void cancellationAfterCommitDoesNotAbortTheBusinessEffect() { + GrpcCancellationCoordinator coordinator = + new GrpcCancellationCoordinator(new GrpcCancellationToken()); + coordinator.markCommitBoundaryCrossed(); + + coordinator.cancel(GrpcCancellationReason.CLIENT_CANCELLED, NOW); + + assertThat(coordinator.cancelled()).isTrue(); + assertThat(coordinator.businessEffectAborted()).isFalse(); + } + + @Test + @DisplayName("cancellation before the commit boundary does report the business effect aborted") + void cancellationBeforeCommitAbortsTheBusinessEffect() { + GrpcCancellationCoordinator coordinator = + new GrpcCancellationCoordinator(new GrpcCancellationToken()); + + coordinator.cancel(GrpcCancellationReason.CLIENT_CANCELLED, NOW); + + assertThat(coordinator.businessEffectAborted()).isTrue(); + } + + @Test + @DisplayName("a deadline reason marks the client as probably retrying, a cancel does not") + void reasonsSayWhetherTheClientIsLikelyRetrying() { + assertThat(GrpcCancellationReason.DEADLINE_EXCEEDED.clientLikelyRetrying()).isTrue(); + assertThat(GrpcCancellationReason.CLIENT_CANCELLED.clientLikelyRetrying()).isFalse(); + assertThat(GrpcCancellationReason.SERVER_DRAIN.canonical()).isEqualTo("server-drain"); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/deadline/GrpcDeadlineCalculatorTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/deadline/GrpcDeadlineCalculatorTest.java new file mode 100644 index 00000000..2c176145 --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/deadline/GrpcDeadlineCalculatorTest.java @@ -0,0 +1,109 @@ +package dev.caskeleton.grpc.deadline; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import dev.caskeleton.grpc.policy.GrpcMethodPolicyCatalog; +import java.time.Duration; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcDeadlineCalculatorTest { + + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + private static final GrpcDeadlineProfile TWO_SECONDS = + GrpcDeadlineProfile.of(Duration.ofSeconds(2)); + private static final GrpcMethodPolicy READ = GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS); + + @Test + @DisplayName("a Stable unary call with no propagated deadline is refused") + void unaryWithoutADeadlineIsRefused() { + assertThatThrownBy(() -> GrpcDeadlineCalculator.forInboundCall(READ, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("positive deadline"); + } + + @Test + @DisplayName("a dependency never gets more time than the inbound call has left") + void dependencyNeverExceedsTheInboundCall() { + GrpcDeadlineBudget inbound = + GrpcDeadlineCalculator.forInboundCall(READ, Duration.ofMillis(400)); + GrpcDependencyBudget generous = GrpcDependencyBudget.of("documents-db", Duration.ofSeconds(30)); + + GrpcDeadlineBudget downstream = GrpcDeadlineCalculator.forDependency(inbound, generous); + + assertThat(downstream.remaining()).isLessThanOrEqualTo(inbound.remaining()); + } + + @Test + @DisplayName("a call is not started when too little time remains to finish it") + void aDoomedDependencyCallIsNotStarted() { + GrpcDeadlineBudget almostSpent = new GrpcDeadlineBudget(Duration.ofMillis(5), TWO_SECONDS); + GrpcDependencyBudget dependency = + new GrpcDependencyBudget("documents-db", Duration.ofSeconds(1), Duration.ofMillis(200)); + + assertThat(GrpcDeadlineCalculator.shouldStart(almostSpent, dependency)).isFalse(); + assertThat( + GrpcDeadlineCalculator.shouldStart( + new GrpcDeadlineBudget(Duration.ofSeconds(1), TWO_SECONDS), dependency)) + .isTrue(); + } + + @Test + @DisplayName("a dependency timeout longer than the inbound deadline fails startup validation") + void aDependencyTimeoutThatCanNeverFireFailsStartup() { + GrpcMethodPolicyCatalog catalog = GrpcMethodPolicyCatalog.builder().register(READ).build(); + Map> dependencies = + Map.of(GET, Set.of(GrpcDependencyBudget.of("documents-db", Duration.ofSeconds(30)))); + + assertThat(GrpcDeadlinePolicyValidator.validate(catalog, dependencies)) + .singleElement() + .satisfies( + violation -> assertThat(violation).contains("documents-db").contains("can never fire")); + } + + @Test + @DisplayName("a dependency that fits inside the usable deadline passes") + void aFittingDependencyPasses() { + GrpcMethodPolicyCatalog catalog = GrpcMethodPolicyCatalog.builder().register(READ).build(); + Map> dependencies = + Map.of(GET, Set.of(GrpcDependencyBudget.of("documents-db", Duration.ofMillis(500)))); + + assertThat(GrpcDeadlinePolicyValidator.validate(catalog, dependencies)).isEmpty(); + } + + @Test + @DisplayName("a method with dependencies but no policy is reported rather than skipped") + void dependenciesOnAnUnknownMethodAreReported() { + GrpcMethodPolicyCatalog empty = + GrpcMethodPolicyCatalog.builder() + .register( + GrpcMethodPolicy.readOnlyUnary( + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/ListDocuments"), + TWO_SECONDS)) + .build(); + Map> dependencies = + Map.of(GET, Set.of(GrpcDependencyBudget.of("documents-db", Duration.ofMillis(100)))); + + assertThat(GrpcDeadlinePolicyValidator.validate(empty, dependencies)) + .singleElement() + .satisfies(violation -> assertThat(violation).contains("has no policy")); + } + + @Test + @DisplayName("a dependency budget whose attempt costs more than its whole timeout is refused") + void anIncoherentDependencyBudgetIsRefused() { + assertThatThrownBy( + () -> + new GrpcDependencyBudget( + "documents-db", Duration.ofMillis(100), Duration.ofMillis(200))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> GrpcDependencyBudget.of("documents-db", Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/error/GrpcErrorMapperTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/error/GrpcErrorMapperTest.java new file mode 100644 index 00000000..87d78ad7 --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/error/GrpcErrorMapperTest.java @@ -0,0 +1,182 @@ +package dev.caskeleton.grpc.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.GrpcStatusCode; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.evidence.GrpcBusinessEvidence; +import dev.caskeleton.grpc.evidence.GrpcExecutionEvidence; +import dev.caskeleton.grpc.evidence.GrpcStreamEvidence; +import dev.caskeleton.grpc.evidence.GrpcTransportEvidence; +import dev.caskeleton.grpc.validation.GrpcValidationViolation; +import io.grpc.Status; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcErrorMapperTest { + + private static final GrpcMethodName CREATE = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument"); + + private final GrpcErrorMapper mapper = new GrpcErrorMapper("document.v1", () -> "exec-0001"); + + private static GrpcExecutionEvidence attempted() { + return new GrpcExecutionEvidence( + CREATE, + RpcType.UNARY, + GrpcTransportEvidence.SENT_UNCONFIRMED, + GrpcBusinessEvidence.ATTEMPTED, + GrpcStreamEvidence.none()); + } + + private static GrpcFailureContext context( + GrpcStatusCode status, + GrpcFailureCategory category, + GrpcCompletionOutcome outcome, + GrpcFailureContext.RetryDisposition disposition) { + return new GrpcFailureContext( + CREATE, + status, + category, + attempted(), + outcome, + disposition, + 1, + Duration.ofMillis(5), + Optional.empty()); + } + + @Test + @DisplayName("the platform status enum and io.grpc.Status translate in both directions") + void statusMappingIsTotalInBothDirections() { + for (GrpcStatusCode code : GrpcStatusCode.values()) { + Status transport = GrpcStatusMapping.toTransport(code); + assertThat(GrpcStatusMapping.fromTransport(transport)).isEqualTo(code); + } + assertThat(GrpcStatusMapping.toTransport(GrpcFailureCategory.VALIDATION).getCode()) + .isEqualTo(Status.Code.INVALID_ARGUMENT); + } + + @Test + @DisplayName("validation maps to INVALID_ARGUMENT with a BadRequest detail") + void validationMapsToInvalidArgument() { + GrpcErrorMapper.MappedError mapped = + mapper.mapValidation(List.of(GrpcValidationViolation.of("document.title", "TOO_LONG"))); + + assertThat(mapped.status().getCode()).isEqualTo(Status.Code.INVALID_ARGUMENT); + assertThat(mapped.details()) + .anySatisfy( + detail -> assertThat(detail).isInstanceOf(GrpcRichErrorDetail.BadRequest.class)); + assertThat(mapped.trailers().get(GrpcErrorMapper.REASON_KEY)).isEqualTo("VALIDATION_FAILED"); + assertThat(mapped.trailers().get(GrpcErrorMapper.EXECUTION_ID_KEY)).isEqualTo("exec-0001"); + } + + @Test + @DisplayName("a state precondition maps to FAILED_PRECONDITION with a typed detail") + void preconditionMapsToFailedPrecondition() { + GrpcErrorMapper.MappedError mapped = + mapper.mapPlatformFailure( + new GrpcPlatformException( + context( + GrpcStatusCode.FAILED_PRECONDITION, + GrpcFailureCategory.PRECONDITION, + GrpcCompletionOutcome.REJECTED, + GrpcFailureContext.RetryDisposition.TERMINAL))); + + assertThat(mapped.status().getCode()).isEqualTo(Status.Code.FAILED_PRECONDITION); + assertThat(mapped.details()) + .anySatisfy( + detail -> + assertThat(detail).isInstanceOf(GrpcRichErrorDetail.PreconditionFailure.class)); + } + + @Test + @DisplayName("a concurrency abort maps to ABORTED") + void concurrencyAbortMapsToAborted() { + GrpcErrorMapper.MappedError mapped = + mapper.mapPlatformFailure( + new GrpcPlatformException( + context( + GrpcStatusCode.ABORTED, + GrpcFailureCategory.CONFLICT, + GrpcCompletionOutcome.REJECTED, + GrpcFailureContext.RetryDisposition.RETRYABLE))); + + assertThat(mapped.status().getCode()).isEqualTo(Status.Code.ABORTED); + } + + @Test + @DisplayName("a retry hint travels only as RetryInfo, and says no when the result is unknown") + void retryHintTravelsOnlyAsRetryInfo() { + GrpcErrorMapper.MappedError resolveFirst = + mapper.mapPlatformFailure( + new GrpcPlatformException( + context( + GrpcStatusCode.DEADLINE_EXCEEDED, + GrpcFailureCategory.DEADLINE, + GrpcCompletionOutcome.COMPLETION_UNKNOWN, + GrpcFailureContext.RetryDisposition.RESOLVE_FIRST))); + + assertThat(resolveFirst.details()) + .filteredOn(GrpcRichErrorDetail.RetryInfo.class::isInstance) + .singleElement() + .satisfies( + detail -> assertThat(((GrpcRichErrorDetail.RetryInfo) detail).retryable()).isFalse()); + assertThat(resolveFirst.trailers().get(GrpcErrorMapper.COMPLETION_OUTCOME_KEY)) + .isEqualTo("COMPLETION_UNKNOWN"); + } + + @Test + @DisplayName( + "an unknown exception becomes INTERNAL with an opaque id and nothing derived from it") + void unknownExceptionsBecomeOpaqueInternal() { + Exception driverFailure = + new IllegalStateException( + "ERROR: duplicate key value violates unique constraint \"documents_pkey\""); + + GrpcErrorMapper.MappedError mapped = mapper.mapUnknown(driverFailure); + + assertThat(mapped.status().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(mapped.status().getDescription()).doesNotContain("documents_pkey"); + assertThat(mapped.status().getDescription()).doesNotContain("IllegalStateException"); + assertThat(mapped.executionId()).isEqualTo("exec-0001"); + assertThat(mapped.status().getCause()).isSameAs(driverFailure); + } + + @Test + @DisplayName("the exposure policy refuses stack frames, SQL, JDBC URLs, tokens, hosts and paths") + void exposurePolicyRefusesLeakyStrings() { + assertThat(GrpcErrorExposurePolicy.safeToExpose("document not found")).isTrue(); + assertThat( + GrpcErrorExposurePolicy.safeToExpose("at dev.caskeleton.Service.run(Service.java:42)")) + .isFalse(); + assertThat(GrpcErrorExposurePolicy.safeToExpose("SQLState: 23505")).isFalse(); + assertThat(GrpcErrorExposurePolicy.safeToExpose("select id from documents")).isFalse(); + assertThat(GrpcErrorExposurePolicy.safeToExpose("jdbc:postgresql://db/app")).isFalse(); + assertThat(GrpcErrorExposurePolicy.safeToExpose("Bearer eyJhbGciOiJIUzI1NiJ9")).isFalse(); + assertThat(GrpcErrorExposurePolicy.safeToExpose("password: hunter2")).isFalse(); + assertThat(GrpcErrorExposurePolicy.safeToExpose("upstream 10.0.3.14:5432 refused")).isFalse(); + assertThat(GrpcErrorExposurePolicy.safeToExpose("/var/lib/app/secrets/key")).isFalse(); + } + + @Test + @DisplayName("an unsafe string is replaced whole, never partially redacted") + void unsafeStringsAreReplacedWhole() { + String replaced = + GrpcErrorExposurePolicy.exposeOr("SQLState: 23505 on documents_pkey", "request failed"); + + assertThat(replaced).isEqualTo("request failed"); + assertThat(replaced).doesNotContain("documents"); + } + + @Test + @DisplayName("a reason constant is derived from a type name in a stable, uppercase form") + void reasonConstantsAreStable() { + assertThat(GrpcErrorExposurePolicy.reasonFor(IllegalStateException.class)) + .isEqualTo("ILLEGAL_STATE_EXCEPTION"); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/idempotency/GrpcCompletionReconcilerTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/idempotency/GrpcCompletionReconcilerTest.java new file mode 100644 index 00000000..e074d50e --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/idempotency/GrpcCompletionReconcilerTest.java @@ -0,0 +1,145 @@ +package dev.caskeleton.grpc.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcCompletionReconcilerTest { + + private static final GrpcMethodName CREATE = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument"); + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + private static final String CALLER = "tenant-1|actor-1"; + private static final String KEY = "order-2026-0001"; + + private final InMemoryOperationLedger ledger = new InMemoryOperationLedger(); + private final GrpcOperationStatusQuery query = + GrpcOperationStatusQuery.withDefaultDeadline(ledger); + + private void claim() { + ledger.claim( + new dev.caskeleton.grpc.ledger.GrpcOperationIdentity( + CALLER, CREATE, GrpcRequestFingerprint.hashIdempotencyKey(KEY)), + GrpcRequestFingerprint.of("body"), + NOW); + } + + @Test + @DisplayName("a status query returns the five documented answers") + void theQueryReturnsTheFiveDocumentedAnswers() { + assertThat(GrpcOperationStatus.values()) + .containsExactly( + GrpcOperationStatus.IN_PROGRESS, + GrpcOperationStatus.COMMITTED, + GrpcOperationStatus.FAILED_TERMINAL, + GrpcOperationStatus.NOT_FOUND, + GrpcOperationStatus.UNKNOWN); + } + + @Test + @DisplayName("no claim means the operation never started, which is safe to re-issue") + void noClaimMeansItNeverStarted() { + GrpcCompletionResolution resolution = query.resolve(CREATE, CALLER, KEY); + + assertThat(resolution.status()).isEqualTo(GrpcOperationStatus.NOT_FOUND); + assertThat(resolution.safeToReissue()).isTrue(); + } + + @Test + @DisplayName("an unreachable ledger is UNKNOWN, which is never safe to re-issue") + void anUnreachableLedgerIsUnknown() { + ledger.makeUnavailable(); + + GrpcCompletionResolution resolution = query.resolve(CREATE, CALLER, KEY); + + assertThat(resolution.status()).isEqualTo(GrpcOperationStatus.UNKNOWN); + assertThat(resolution.safeToReissue()).isFalse(); + assertThat(resolution.requiresReconciliation()).isTrue(); + } + + @Test + @DisplayName("a committed operation is answered with its stored outcome, not a fresh one") + void aCommittedOperationReturnsItsStoredOutcome() { + claim(); + ledger.markCommitted( + new dev.caskeleton.grpc.ledger.GrpcOperationIdentity( + CALLER, CREATE, GrpcRequestFingerprint.hashIdempotencyKey(KEY)), + "outcome://document/42", + NOW.plusSeconds(1)); + + GrpcCompletionResolution resolution = query.resolve(CREATE, CALLER, KEY); + + assertThat(resolution.status()).isEqualTo(GrpcOperationStatus.COMMITTED); + assertThat(resolution.outcomeReference()).contains("outcome://document/42"); + assertThat(resolution.safeToReissue()).isFalse(); + } + + @Test + @DisplayName("the status query runs under its own short read-only deadline") + void theQueryHasItsOwnDeadline() { + assertThat(query.queryDeadline().total()).isEqualTo(java.time.Duration.ofSeconds(2)); + } + + @Test + @DisplayName("the ledger is consulted before any business probe") + void theLedgerIsConsultedFirst() { + claim(); + ledger.markCommitted( + new dev.caskeleton.grpc.ledger.GrpcOperationIdentity( + CALLER, CREATE, GrpcRequestFingerprint.hashIdempotencyKey(KEY)), + "outcome://from-ledger", + NOW.plusSeconds(1)); + GrpcCompletionReconciler reconciler = new GrpcCompletionReconciler(query); + + GrpcCompletionResolution resolution = + reconciler.reconcile( + CREATE, CALLER, KEY, method -> Optional.of("outcome://from-business-probe")); + + assertThat(resolution.outcomeReference()).contains("outcome://from-ledger"); + } + + @Test + @DisplayName("a business probe is consulted only when the ledger holds nothing") + void aBusinessProbeIsTheSecondSource() { + GrpcCompletionReconciler reconciler = new GrpcCompletionReconciler(query); + + GrpcCompletionResolution resolution = + reconciler.reconcile(CREATE, CALLER, KEY, method -> Optional.of("outcome://observed")); + + assertThat(resolution.status()).isEqualTo(GrpcOperationStatus.COMMITTED); + assertThat(resolution.reason()).contains("business resource"); + } + + @Test + @DisplayName("an unresolvable case stays UNKNOWN and is queued rather than guessed") + void anUnresolvableCaseIsQueued() { + ledger.makeUnavailable(); + GrpcCompletionReconciler reconciler = new GrpcCompletionReconciler(query); + + GrpcCompletionResolution resolution = reconciler.reconcile(CREATE, CALLER, KEY, null); + + assertThat(resolution.status()).isEqualTo(GrpcOperationStatus.UNKNOWN); + assertThat(reconciler.pendingCases()) + .singleElement() + .satisfies(pending -> assertThat(pending.method()).isEqualTo(CREATE)); + + reconciler.clearPending(reconciler.pendingCases().get(0)); + assertThat(reconciler.pendingCases()).isEmpty(); + } + + @Test + @DisplayName("a running claim is queued rather than answered") + void aRunningClaimIsQueued() { + claim(); + GrpcCompletionReconciler reconciler = new GrpcCompletionReconciler(query); + + GrpcCompletionResolution resolution = reconciler.reconcile(CREATE, CALLER, KEY, null); + + assertThat(resolution.status()).isEqualTo(GrpcOperationStatus.IN_PROGRESS); + assertThat(reconciler.pendingCases()).hasSize(1); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/idempotency/GrpcIdempotencyInterceptorTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/idempotency/GrpcIdempotencyInterceptorTest.java new file mode 100644 index 00000000..38b2dcfc --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/idempotency/GrpcIdempotencyInterceptorTest.java @@ -0,0 +1,171 @@ +package dev.caskeleton.grpc.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import dev.caskeleton.grpc.policy.GrpcMethodPolicyCatalog; +import dev.caskeleton.grpc.policy.RpcIdempotencyProfile; +import dev.caskeleton.grpc.policy.WaitForReadyPolicy; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcIdempotencyInterceptorTest { + + private static final GrpcMethodName CREATE = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument"); + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + private static final GrpcDeadlineProfile TEN_SECONDS = + GrpcDeadlineProfile.of(Duration.ofSeconds(10)); + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + private static final String CALLER = "tenant-1|actor-1"; + private static final String KEY = "order-2026-0001"; + + private final InMemoryOperationLedger ledger = new InMemoryOperationLedger(); + + private static GrpcMethodPolicyCatalog catalog() { + return GrpcMethodPolicyCatalog.builder() + .register( + new GrpcMethodPolicy( + CREATE, + RpcType.UNARY, + RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED, + TEN_SECONDS, + WaitForReadyPolicy.DISABLED, + true, + 1024, + 1024)) + .register(GrpcMethodPolicy.readOnlyUnary(GET, TEN_SECONDS)) + .build(); + } + + private GrpcIdempotencyInterceptor interceptor(boolean waitForInProgress) { + return new GrpcIdempotencyInterceptor( + ledger, catalog(), Duration.ofMillis(250), waitForInProgress); + } + + @Test + @DisplayName("a method that requires a key refuses a call without one") + void aMissingKeyIsRefusedRatherThanGenerated() { + assertThatThrownBy( + () -> + interceptor(false) + .decide(CREATE, CALLER, null, GrpcRequestFingerprint.of("body"), NOW)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("server-generated key"); + } + + @Test + @DisplayName("a method that does not require a key proceeds without one") + void aMethodWithoutTheRequirementProceeds() { + assertThat( + interceptor(false) + .decide(GET, CALLER, null, GrpcRequestFingerprint.of("body"), NOW) + .shouldProceed()) + .isTrue(); + } + + @Test + @DisplayName("the first caller claims the operation and proceeds") + void theFirstCallerClaimsAndProceeds() { + GrpcIdempotencyDecision decision = + interceptor(false).decide(CREATE, CALLER, KEY, GrpcRequestFingerprint.of("body"), NOW); + + assertThat(decision.outcome()).isEqualTo(GrpcIdempotencyDecision.Outcome.PROCEED); + } + + @Test + @DisplayName("a duplicate of a committed operation replays the stored outcome") + void aDuplicateOfACommittedOperationReplays() { + GrpcIdempotencyInterceptor interceptor = interceptor(false); + String fingerprint = GrpcRequestFingerprint.of("body"); + interceptor.decide(CREATE, CALLER, KEY, fingerprint, NOW); + interceptor.recordCommit(CREATE, CALLER, KEY, "outcome://document/42", NOW.plusSeconds(1)); + + GrpcIdempotencyDecision replayed = + interceptor.decide(CREATE, CALLER, KEY, fingerprint, NOW.plusSeconds(2)); + + assertThat(replayed.outcome()).isEqualTo(GrpcIdempotencyDecision.Outcome.REPLAY); + assertThat(replayed.outcomeReference()).contains("outcome://document/42"); + assertThat(replayed.shouldProceed()).isFalse(); + } + + @Test + @DisplayName("the same key with a different request is a precondition failure, not a duplicate") + void aReusedKeyWithADifferentRequestIsRefused() { + GrpcIdempotencyInterceptor interceptor = interceptor(false); + interceptor.decide(CREATE, CALLER, KEY, GrpcRequestFingerprint.of("body-a"), NOW); + + GrpcIdempotencyDecision decision = + interceptor.decide(CREATE, CALLER, KEY, GrpcRequestFingerprint.of("body-b"), NOW); + + assertThat(decision.outcome()).isEqualTo(GrpcIdempotencyDecision.Outcome.FINGERPRINT_MISMATCH); + } + + @Test + @DisplayName("a duplicate of a running operation waits or is refused, per the method's policy") + void aDuplicateOfARunningOperationWaitsOrIsRefused() { + String fingerprint = GrpcRequestFingerprint.of("body"); + GrpcIdempotencyInterceptor waiting = interceptor(true); + waiting.decide(CREATE, CALLER, KEY, fingerprint, NOW); + + assertThat(waiting.decide(CREATE, CALLER, KEY, fingerprint, NOW).outcome()) + .isEqualTo(GrpcIdempotencyDecision.Outcome.WAIT_AND_POLL); + assertThat(interceptor(false).decide(CREATE, CALLER, KEY, fingerprint, NOW).outcome()) + .isEqualTo(GrpcIdempotencyDecision.Outcome.REJECT_IN_PROGRESS); + } + + @Test + @DisplayName("a terminally failed operation may be attempted again") + void aTerminallyFailedOperationMayBeRetried() { + GrpcIdempotencyInterceptor interceptor = interceptor(false); + String fingerprint = GrpcRequestFingerprint.of("body"); + interceptor.decide(CREATE, CALLER, KEY, fingerprint, NOW); + interceptor.recordFailure(CREATE, CALLER, KEY, NOW.plusSeconds(1)); + + assertThat(interceptor.decide(CREATE, CALLER, KEY, fingerprint, NOW.plusSeconds(2)).outcome()) + .isEqualTo(GrpcIdempotencyDecision.Outcome.PROCEED); + } + + @Test + @DisplayName("one caller's key does not suppress another caller's write") + void oneCallersKeyDoesNotSuppressAnother() { + GrpcIdempotencyInterceptor interceptor = interceptor(false); + String fingerprint = GrpcRequestFingerprint.of("body"); + interceptor.decide(CREATE, "tenant-1|actor-1", KEY, fingerprint, NOW); + + assertThat(interceptor.decide(CREATE, "tenant-2|actor-9", KEY, fingerprint, NOW).outcome()) + .isEqualTo(GrpcIdempotencyDecision.Outcome.PROCEED); + } + + @Test + @DisplayName("the idempotency key is hashed rather than stored, and the storage key is loggable") + void theKeyIsHashedNotStored() { + String hashed = GrpcRequestFingerprint.hashIdempotencyKey(KEY); + + assertThat(hashed).startsWith("sha256:").doesNotContain(KEY); + assertThat( + new dev.caskeleton.grpc.ledger.GrpcOperationIdentity(CALLER, CREATE, hashed) + .storageKey()) + .doesNotContain(KEY); + } + + @Test + @DisplayName("a stored outcome larger than the inline limit is refused rather than truncated") + void anOversizedOutcomeIsRefused() { + GrpcOutcomeReplay replay = new GrpcOutcomeReplay(16); + + replay.store("outcome://small", new byte[16]); + assertThat(replay.replay("outcome://small")).isPresent(); + assertThat(replay.size()).isEqualTo(1); + assertThatThrownBy(() -> replay.store("outcome://big", new byte[17])) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("object reference"); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/idempotency/InMemoryOperationLedger.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/idempotency/InMemoryOperationLedger.java new file mode 100644 index 00000000..25f33ac3 --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/idempotency/InMemoryOperationLedger.java @@ -0,0 +1,81 @@ +package dev.caskeleton.grpc.idempotency; + +import dev.caskeleton.grpc.ledger.GrpcOperationIdentity; +import dev.caskeleton.grpc.ledger.GrpcOperationLedger; +import dev.caskeleton.grpc.ledger.GrpcOperationLedgerRecord; +import dev.caskeleton.grpc.ledger.GrpcOperationLedgerState; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * A hand-rolled ledger for the policy tests. + * + *

{@code putIfAbsent} on a concurrent map is the in-memory equivalent of the unique constraint + * the real adapter relies on, so the claim is atomic here for the same reason it is there. A fake + * that read and then wrote would let the policy tests pass while the property they exist to check + * does not hold. + */ +final class InMemoryOperationLedger implements GrpcOperationLedger { + + private final ConcurrentMap records = + new ConcurrentHashMap<>(); + private boolean unavailable; + + /** Makes every read fail, so the UNKNOWN path can be exercised. */ + void makeUnavailable() { + unavailable = true; + } + + @Override + public Optional claim( + GrpcOperationIdentity identity, String requestFingerprint, Instant now) { + GrpcOperationLedgerRecord existing = + records.putIfAbsent( + identity.storageKey(), + GrpcOperationLedgerRecord.claim(identity, requestFingerprint, now)); + return Optional.ofNullable(existing); + } + + @Override + public Optional find(GrpcOperationIdentity identity) { + if (unavailable) { + throw new IllegalStateException("ledger unavailable"); + } + return Optional.ofNullable(records.get(identity.storageKey())); + } + + @Override + public void markCommitted(GrpcOperationIdentity identity, String outcomeReference, Instant now) { + records.computeIfPresent( + identity.storageKey(), + (key, existing) -> + new GrpcOperationLedgerRecord( + existing.identity(), + GrpcOperationLedgerState.COMMITTED, + existing.requestFingerprint(), + Optional.of(outcomeReference), + existing.claimedAt(), + Optional.of(now))); + } + + @Override + public void markFailed(GrpcOperationIdentity identity, Instant now) { + records.computeIfPresent( + identity.storageKey(), + (key, existing) -> + new GrpcOperationLedgerRecord( + existing.identity(), + GrpcOperationLedgerState.FAILED_TERMINAL, + existing.requestFingerprint(), + Optional.empty(), + existing.claimedAt(), + Optional.of(now))); + } + + @Override + public void release(GrpcOperationIdentity identity) { + records.remove(identity.storageKey()); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/policy/GrpcPayloadBoundaryPolicyTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/policy/GrpcPayloadBoundaryPolicyTest.java new file mode 100644 index 00000000..e048f775 --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/policy/GrpcPayloadBoundaryPolicyTest.java @@ -0,0 +1,130 @@ +package dev.caskeleton.grpc.policy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcPayloadBoundaryPolicyTest { + + private static GrpcPayloadBoundaryPolicy.Measurement measurement( + long compressed, + long uncompressed, + long metadata, + int collection, + int fieldLength, + int depth, + long binary) { + return new GrpcPayloadBoundaryPolicy.Measurement( + compressed, uncompressed, metadata, collection, fieldLength, depth, binary); + } + + private static GrpcPayloadBoundaryPolicy standardPolicy() { + return new GrpcPayloadBoundaryPolicy(GrpcMessageSizeProfile.standard(), 64 * 1024L); + } + + @Test + @DisplayName("the default bound is a chosen number, not the Protobuf maximum") + void theDefaultBoundIsChosen() { + GrpcMessageSizeProfile standard = GrpcMessageSizeProfile.standard(); + + assertThat(standard.maxInboundMessageBytes()).isEqualTo(1024L * 1024L); + assertThat(standard.compression()).isEqualTo(GrpcCompressionProfile.IDENTITY); + assertThat(GrpcMessageSizeProfile.largeMessageOptIn().compression()) + .isEqualTo(GrpcCompressionProfile.GZIP); + } + + @Test + @DisplayName("a message within its profile produces no violation") + void aWellSizedMessagePasses() { + assertThat(standardPolicy().check(measurement(1024L, 1024L, 512L, 10, 128, 3, 0L))).isEmpty(); + } + + @Test + @DisplayName("message, metadata, collection, field length and nesting bounds are each reported") + void everyBoundIsReportedSeparately() { + List violations = + standardPolicy() + .check( + measurement(2L * 1024L * 1024L, 2L * 1024L * 1024L, 9000L, 5000, 100_000, 20, 0L)); + + assertThat(violations) + .extracting(GrpcSizeViolation::bound) + .contains( + GrpcSizeViolation.Bound.COMPRESSED_MESSAGE, + GrpcSizeViolation.Bound.UNCOMPRESSED_MESSAGE, + GrpcSizeViolation.Bound.METADATA, + GrpcSizeViolation.Bound.COLLECTION_COUNT, + GrpcSizeViolation.Bound.FIELD_LENGTH, + GrpcSizeViolation.Bound.NESTING_DEPTH); + } + + @Test + @DisplayName("a decompression bomb is refused on its ratio, before the buffer would be allocated") + void aDecompressionBombIsRefusedOnRatio() { + GrpcPayloadBoundaryPolicy compressed = + new GrpcPayloadBoundaryPolicy(GrpcMessageSizeProfile.largeMessageOptIn(), 64 * 1024L); + + List violations = + compressed.check(measurement(1024L, 1024L * 1024L, 512L, 10, 128, 3, 0L)); + + assertThat(violations) + .extracting(GrpcSizeViolation::bound) + .contains(GrpcSizeViolation.Bound.DECOMPRESSION_RATIO); + assertThat(GrpcCompressionProfile.GZIP.ratioAcceptable(1024L, 1024L * 50L)).isTrue(); + assertThat(GrpcCompressionProfile.GZIP.ratioAcceptable(1024L, 1024L * 51L)).isFalse(); + } + + @Test + @DisplayName("large binary becomes an object reference rather than a bigger message bound") + void largeBinaryBecomesAReference() { + GrpcPayloadBoundaryPolicy policy = standardPolicy(); + + assertThat(policy.requiresObjectReference(64 * 1024L)).isFalse(); + assertThat(policy.requiresObjectReference(64 * 1024L + 1)).isTrue(); + } + + @Test + @DisplayName("already-compressed content is not compressed again") + void alreadyCompressedContentIsNotRecompressed() { + GrpcPayloadBoundaryPolicy compressed = + new GrpcPayloadBoundaryPolicy(GrpcMessageSizeProfile.largeMessageOptIn(), 64 * 1024L); + + assertThat(compressed.shouldCompress(false)).isTrue(); + assertThat(compressed.shouldCompress(true)).isFalse(); + assertThat(standardPolicy().shouldCompress(false)).isFalse(); + } + + @Test + @DisplayName("an inline binary threshold above the message bound is refused") + void anUnreachableThresholdIsRefused() { + assertThatThrownBy( + () -> + new GrpcPayloadBoundaryPolicy( + GrpcMessageSizeProfile.standard(), 64L * 1024L * 1024L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("can never be reached"); + } + + @Test + @DisplayName("a nesting depth that would drive deep parsing recursion is refused") + void anUnboundedNestingDepthIsRefused() { + assertThatThrownBy( + () -> + new GrpcMessageSizeProfile( + 1024L, 1024L, 1024L, 10, 10, 64, GrpcCompressionProfile.IDENTITY)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("deep recursion"); + } + + @Test + @DisplayName("a violation renders with the observed and permitted numbers only") + void aViolationRendersNumbersNotContent() { + GrpcSizeViolation violation = + new GrpcSizeViolation(GrpcSizeViolation.Bound.FIELD_LENGTH, "document.title", 9000, 8192); + + assertThat(violation.describe()).isEqualTo("FIELD_LENGTH document.title: 9000 > 8192"); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/resilience/GrpcRetryEligibilityTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/resilience/GrpcRetryEligibilityTest.java new file mode 100644 index 00000000..2d189033 --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/resilience/GrpcRetryEligibilityTest.java @@ -0,0 +1,309 @@ +package dev.caskeleton.grpc.resilience; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.GrpcStatusCode; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; +import dev.caskeleton.grpc.evidence.GrpcBusinessEvidence; +import dev.caskeleton.grpc.evidence.GrpcExecutionEvidence; +import dev.caskeleton.grpc.evidence.GrpcStreamEvidence; +import dev.caskeleton.grpc.evidence.GrpcTransportEvidence; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import dev.caskeleton.grpc.policy.RpcIdempotencyProfile; +import dev.caskeleton.grpc.policy.WaitForReadyPolicy; +import java.time.Duration; +import java.util.random.RandomGenerator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcRetryEligibilityTest { + + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + private static final GrpcMethodName CREATE = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument"); + private static final GrpcMethodName WATCH = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/WatchDocuments"); + private static final GrpcDeadlineProfile TEN_SECONDS = + GrpcDeadlineProfile.of(Duration.ofSeconds(10)); + private static final GrpcMethodPolicy READ = GrpcMethodPolicy.readOnlyUnary(GET, TEN_SECONDS); + private static final GrpcMethodPolicy NON_IDEMPOTENT = + GrpcMethodPolicy.nonIdempotentUnary(CREATE, TEN_SECONDS); + private static final GrpcMethodPolicy KEYED = + new GrpcMethodPolicy( + CREATE, + RpcType.UNARY, + RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED, + TEN_SECONDS, + WaitForReadyPolicy.DISABLED, + true, + 1024, + 1024); + + /** A deterministic jitter source, so the backoff schedule is assertable rather than a range. */ + private static final RandomGenerator NO_JITTER = + new RandomGenerator() { + @Override + public long nextLong() { + return 0L; + } + + @Override + public long nextLong(long origin, long bound) { + return 0L; + } + }; + + private static GrpcExecutionEvidence sentUnanswered(GrpcMethodName method, RpcType type) { + return new GrpcExecutionEvidence( + method, + type, + GrpcTransportEvidence.SENT_UNCONFIRMED, + GrpcBusinessEvidence.ATTEMPTED, + GrpcStreamEvidence.none()); + } + + private static GrpcDeadlineBudget plentyOfTime() { + return new GrpcDeadlineBudget(Duration.ofSeconds(9), TEN_SECONDS); + } + + @Test + @DisplayName("a non-idempotent method is never retried explicitly") + void nonIdempotentMethodsAreNeverRetried() { + GrpcRetryDecision decision = + GrpcRetryEligibility.refuseIfIneligible( + NON_IDEMPOTENT, + sentUnanswered(CREATE, RpcType.UNARY), + GrpcStatusCode.UNAVAILABLE, + GrpcRetryEligibility.Capabilities.none(), + GrpcRetryOwner.GRPC_PLATFORM); + + assertThat(decision).isNotNull(); + assertThat(decision.verdict()).isEqualTo(GrpcRetryDecision.Verdict.UNSAFE_TO_REPEAT); + } + + @Test + @DisplayName("a keyed mutation is retryable only with both a key and a ledger") + void keyedMutationsNeedBothAKeyAndALedger() { + GrpcExecutionEvidence notSent = GrpcExecutionEvidence.notStarted(CREATE, RpcType.UNARY); + + assertThat( + GrpcRetryEligibility.refuseIfIneligible( + KEYED, + notSent, + GrpcStatusCode.UNAVAILABLE, + new GrpcRetryEligibility.Capabilities(true, false), + GrpcRetryOwner.GRPC_PLATFORM)) + .isNotNull(); + assertThat( + GrpcRetryEligibility.refuseIfIneligible( + KEYED, + notSent, + GrpcStatusCode.UNAVAILABLE, + GrpcRetryEligibility.Capabilities.keyedWithLedger(), + GrpcRetryOwner.GRPC_PLATFORM)) + .isNull(); + } + + @Test + @DisplayName("a mutation that ran out of time is resolved rather than retried") + void deadlineOnAMutationResolvesRatherThanRetries() { + GrpcRetryDecision decision = + GrpcRetryEligibility.refuseIfIneligible( + KEYED, + sentUnanswered(CREATE, RpcType.UNARY), + GrpcStatusCode.DEADLINE_EXCEEDED, + GrpcRetryEligibility.Capabilities.keyedWithLedger(), + GrpcRetryOwner.GRPC_PLATFORM); + + assertThat(decision).isNotNull(); + assertThat(decision.verdict()).isEqualTo(GrpcRetryDecision.Verdict.RESOLVE_COMPLETION_FIRST); + } + + @Test + @DisplayName("UNAVAILABLE after the request was sent does not license a mutation retry") + void unavailableAfterSendResolvesRatherThanRetries() { + GrpcRetryDecision decision = + GrpcRetryEligibility.refuseIfIneligible( + KEYED, + sentUnanswered(CREATE, RpcType.UNARY), + GrpcStatusCode.UNAVAILABLE, + GrpcRetryEligibility.Capabilities.keyedWithLedger(), + GrpcRetryOwner.GRPC_PLATFORM); + + assertThat(decision).isNotNull(); + assertThat(decision.verdict()).isEqualTo(GrpcRetryDecision.Verdict.RESOLVE_COMPLETION_FIRST); + } + + @Test + @DisplayName("a delivered stream prefix forbids a whole-call retry") + void aDeliveredPrefixForbidsAWholeCallRetry() { + GrpcMethodPolicy streaming = + new GrpcMethodPolicy( + WATCH, + RpcType.SERVER_STREAMING, + RpcIdempotencyProfile.READ_ONLY, + TEN_SECONDS, + WaitForReadyPolicy.DISABLED, + false, + 1024, + 1024); + GrpcExecutionEvidence partial = + new GrpcExecutionEvidence( + WATCH, + RpcType.SERVER_STREAMING, + GrpcTransportEvidence.RESPONSE_MESSAGE_SEEN, + GrpcBusinessEvidence.NONE, + new GrpcStreamEvidence.Partial(7L)); + + GrpcRetryDecision decision = + GrpcRetryEligibility.refuseIfIneligible( + streaming, + partial, + GrpcStatusCode.UNAVAILABLE, + GrpcRetryEligibility.Capabilities.none(), + GrpcRetryOwner.GRPC_PLATFORM); + + assertThat(decision).isNotNull(); + assertThat(decision.reason()).contains("redeliver"); + } + + @Test + @DisplayName("a process that is not the retry owner refuses before anything else") + void aNonOwnerRefusesFirst() { + GrpcRetryDecision decision = + GrpcRetryEligibility.refuseIfIneligible( + READ, + GrpcExecutionEvidence.notStarted(GET, RpcType.UNARY), + GrpcStatusCode.UNAVAILABLE, + GrpcRetryEligibility.Capabilities.none(), + GrpcRetryOwner.SERVICE_MESH); + + assertThat(decision).isNotNull(); + assertThat(decision.verdict()).isEqualTo(GrpcRetryDecision.Verdict.NOT_THE_OWNER); + } + + @Test + @DisplayName("a read retries within its attempt limit and then stops") + void aReadRetriesWithinItsAttemptLimit() { + GrpcRetryCoordinator coordinator = + new GrpcRetryCoordinator( + GrpcRetryOwner.GRPC_PLATFORM, GrpcRetryBudget.of(0.5d, 100L), NO_JITTER); + GrpcMethodRetryConfig config = GrpcMethodRetryConfig.readDefault(GET); + GrpcExecutionEvidence notSent = GrpcExecutionEvidence.notStarted(GET, RpcType.UNARY); + + GrpcRetryDecision first = + coordinator.decide( + READ, + config, + notSent, + GrpcStatusCode.UNAVAILABLE, + GrpcRetryEligibility.Capabilities.none(), + 1, + plentyOfTime()); + GrpcRetryDecision last = + coordinator.decide( + READ, + config, + notSent, + GrpcStatusCode.UNAVAILABLE, + GrpcRetryEligibility.Capabilities.none(), + 3, + plentyOfTime()); + + assertThat(first.shouldRetry()).isTrue(); + assertThat(first.backoff()).isEqualTo(Duration.ofMillis(100)); + assertThat(last.shouldRetry()).isFalse(); + assertThat(last.verdict()).isEqualTo(GrpcRetryDecision.Verdict.TERMINAL); + } + + @Test + @DisplayName("a status outside the retryable set is terminal") + void anUnretryableStatusIsTerminal() { + GrpcRetryCoordinator coordinator = + new GrpcRetryCoordinator( + GrpcRetryOwner.GRPC_PLATFORM, GrpcRetryBudget.of(0.5d, 100L), NO_JITTER); + + GrpcRetryDecision decision = + coordinator.decide( + READ, + GrpcMethodRetryConfig.readDefault(GET), + GrpcExecutionEvidence.notStarted(GET, RpcType.UNARY), + GrpcStatusCode.NOT_FOUND, + GrpcRetryEligibility.Capabilities.none(), + 1, + plentyOfTime()); + + assertThat(decision.verdict()).isEqualTo(GrpcRetryDecision.Verdict.TERMINAL); + } + + @Test + @DisplayName("a retry that could not finish inside the remaining deadline is refused") + void aRetryThatCannotFinishIsRefused() { + GrpcRetryCoordinator coordinator = + new GrpcRetryCoordinator( + GrpcRetryOwner.GRPC_PLATFORM, GrpcRetryBudget.of(0.5d, 100L), NO_JITTER); + + GrpcRetryDecision decision = + coordinator.decide( + READ, + GrpcMethodRetryConfig.readDefault(GET), + GrpcExecutionEvidence.notStarted(GET, RpcType.UNARY), + GrpcStatusCode.UNAVAILABLE, + GrpcRetryEligibility.Capabilities.none(), + 1, + new GrpcDeadlineBudget(Duration.ofMillis(50), TEN_SECONDS)); + + assertThat(decision.shouldRetry()).isFalse(); + assertThat(decision.reason()).contains("backoff"); + } + + @Test + @DisplayName("an exhausted budget stops retries even when everything else permits one") + void anExhaustedBudgetStopsRetries() { + GrpcRetryBudget budget = GrpcRetryBudget.of(1.0d, 1L); + GrpcRetryCoordinator coordinator = + new GrpcRetryCoordinator(GrpcRetryOwner.GRPC_PLATFORM, budget, NO_JITTER); + GrpcMethodRetryConfig config = GrpcMethodRetryConfig.readDefault(GET); + GrpcExecutionEvidence notSent = GrpcExecutionEvidence.notStarted(GET, RpcType.UNARY); + + assertThat( + coordinator + .decide( + READ, + config, + notSent, + GrpcStatusCode.UNAVAILABLE, + GrpcRetryEligibility.Capabilities.none(), + 1, + plentyOfTime()) + .shouldRetry()) + .isTrue(); + GrpcRetryDecision second = + coordinator.decide( + READ, + config, + notSent, + GrpcStatusCode.UNAVAILABLE, + GrpcRetryEligibility.Capabilities.none(), + 1, + plentyOfTime()); + + assertThat(second.verdict()).isEqualTo(GrpcRetryDecision.Verdict.BUDGET_EXHAUSTED); + assertThat(budget.exhausted()).isTrue(); + + coordinator.recordSuccess(); + assertThat(budget.exhausted()).isFalse(); + } + + @Test + @DisplayName("a retry budget refuses a ratio that permits a retry per call") + void aBudgetRefusesAnUnboundedRatio() { + assertThat(GrpcRetryBudget.of(0.2d, 100L).availableTokens()).isEqualTo(100L); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> GrpcRetryBudget.of(1.5d, 10L)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/resilience/GrpcRetryOwnershipValidatorTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/resilience/GrpcRetryOwnershipValidatorTest.java new file mode 100644 index 00000000..9127f91c --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/resilience/GrpcRetryOwnershipValidatorTest.java @@ -0,0 +1,177 @@ +package dev.caskeleton.grpc.resilience; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.GrpcStatusCode; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import dev.caskeleton.grpc.policy.GrpcMethodPolicyCatalog; +import dev.caskeleton.grpc.policy.RpcIdempotencyProfile; +import dev.caskeleton.grpc.policy.WaitForReadyPolicy; +import java.time.Duration; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcRetryOwnershipValidatorTest { + + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + private static final GrpcMethodName CREATE = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument"); + private static final GrpcMethodName WATCH = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/WatchDocuments"); + private static final GrpcDeadlineProfile TWO_SECONDS = + GrpcDeadlineProfile.of(Duration.ofSeconds(2)); + + private static GrpcMethodPolicyCatalog catalog() { + return GrpcMethodPolicyCatalog.builder() + .register(GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS)) + .register(GrpcMethodPolicy.nonIdempotentUnary(CREATE, TWO_SECONDS)) + .register( + new GrpcMethodPolicy( + WATCH, + RpcType.SERVER_STREAMING, + RpcIdempotencyProfile.STREAMING, + TWO_SECONDS, + WaitForReadyPolicy.DISABLED, + false, + 1024, + 1024)) + .build(); + } + + @Test + @DisplayName("exactly one owner may retry in-process") + void onlyOneOwnerRetriesInProcess() { + assertThat(GrpcRetryOwner.APPLICATION.explicitRetryInProcess()).isTrue(); + assertThat(GrpcRetryOwner.GRPC_PLATFORM.explicitRetryInProcess()).isTrue(); + assertThat(GrpcRetryOwner.SERVICE_MESH.explicitRetryInProcess()).isFalse(); + assertThat(GrpcRetryOwner.NONE.explicitRetryInProcess()).isFalse(); + assertThat(GrpcRetryOwner.GRPC_PLATFORM.hedgingAllowed()).isTrue(); + assertThat(GrpcRetryOwner.SERVICE_MESH.hedgingAllowed()).isFalse(); + } + + @Test + @DisplayName("a mesh-owned channel refuses an in-process retry entry outright") + void aMeshOwnedChannelRefusesInProcessRetries() { + assertThatThrownBy( + () -> + new GrpcServiceConfigPolicy( + GrpcRetryOwner.SERVICE_MESH, + Map.of(GET, GrpcMethodRetryConfig.readDefault(GET)), + true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("multiply the load"); + } + + @Test + @DisplayName("a mesh-owned channel disables hedging") + void aMeshOwnedChannelDisablesHedging() { + GrpcServiceConfigPolicy mesh = + GrpcServiceConfigPolicy.meshOwned(Map.of(GET, GrpcMethodRetryConfig.disabled(GET))); + + assertThat(mesh.hedgingAllowed()).isFalse(); + assertThat(GrpcRetryOwnershipValidator.validate(mesh, catalog())).isEmpty(); + } + + @Test + @DisplayName("a service config entry naming a method the catalog does not declare is reported") + void aRenamedMethodLeavesItsEntryMatchingNothing() { + GrpcMethodName renamed = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocumentV2"); + GrpcServiceConfigPolicy config = + new GrpcServiceConfigPolicy( + GrpcRetryOwner.GRPC_PLATFORM, + Map.of(renamed, GrpcMethodRetryConfig.readDefault(renamed)), + false); + + assertThat(GrpcRetryOwnershipValidator.validate(config, catalog())) + .anySatisfy( + violation -> + assertThat(violation).contains("CreateDocumentV2").contains("matching nothing")); + } + + @Test + @DisplayName("a retry entry on a non-idempotent or streaming method is reported") + void retryEntriesOnUnsafeMethodsAreReported() { + GrpcServiceConfigPolicy config = + new GrpcServiceConfigPolicy( + GrpcRetryOwner.GRPC_PLATFORM, + Map.of( + CREATE, GrpcMethodRetryConfig.readDefault(CREATE), + WATCH, GrpcMethodRetryConfig.readDefault(WATCH)), + false); + + // Three, not two: the streaming method breaks both rules, and both are reported so a reader + // sees the whole shape rather than fixing one and rediscovering the other. + assertThat(GrpcRetryOwnershipValidator.validate(config, catalog())) + .hasSize(3) + .anySatisfy(violation -> assertThat(violation).contains("NON_IDEMPOTENT")) + .anySatisfy(violation -> assertThat(violation).contains("STREAMING")) + .anySatisfy(violation -> assertThat(violation).contains("delivered prefix")); + } + + @Test + @DisplayName("no entry and a disabled entry are different states") + void noEntryDiffersFromADisabledEntry() { + GrpcServiceConfigPolicy config = + new GrpcServiceConfigPolicy( + GrpcRetryOwner.GRPC_PLATFORM, + Map.of(CREATE, GrpcMethodRetryConfig.disabled(CREATE)), + false); + + assertThat(config.configFor(CREATE)).isPresent(); + assertThat(config.explicitlyDisabled(CREATE)).isTrue(); + assertThat(config.configFor(GET)).isEmpty(); + assertThat(config.explicitlyDisabled(GET)).isFalse(); + } + + @Test + @DisplayName("transparent retry is recorded in the snapshot even when nobody configured a retry") + void transparentRetryIsRecorded() { + GrpcServiceConfigPolicy config = + new GrpcServiceConfigPolicy(GrpcRetryOwner.NONE, Map.of(), true); + + assertThat(config.transparentRetryPresent()).isTrue(); + assertThat(config.retryOwner()).isEqualTo(GrpcRetryOwner.NONE); + } + + @Test + @DisplayName("a retry config with attempts but no retryable codes is refused") + void aRetryConfigThatNeverRetriesIsRefused() { + assertThatThrownBy( + () -> + new GrpcMethodRetryConfig( + GET, 3, Duration.ofMillis(10), Duration.ofSeconds(1), 2.0d, 0.1d, Set.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("never retries while looking as if it does"); + assertThatThrownBy( + () -> + new GrpcMethodRetryConfig( + GET, + 9, + Duration.ofMillis(10), + Duration.ofSeconds(1), + 2.0d, + 0.1d, + Set.of(GrpcStatusCode.UNAVAILABLE))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("multiplies load"); + } + + @Test + @DisplayName("backoff grows exponentially and is capped") + void backoffGrowsAndIsCapped() { + GrpcMethodRetryConfig config = GrpcMethodRetryConfig.readDefault(GET); + + assertThat(config.backoffBefore(1)).isEqualTo(Duration.ZERO); + assertThat(config.backoffBefore(2)).isEqualTo(Duration.ofMillis(100)); + assertThat(config.backoffBefore(3)).isEqualTo(Duration.ofMillis(200)); + assertThat(config.backoffBefore(20)).isEqualTo(Duration.ofSeconds(2)); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/resilience/GrpcWaitForReadyValidatorTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/resilience/GrpcWaitForReadyValidatorTest.java new file mode 100644 index 00000000..fcb783c7 --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/resilience/GrpcWaitForReadyValidatorTest.java @@ -0,0 +1,144 @@ +package dev.caskeleton.grpc.resilience; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import dev.caskeleton.grpc.policy.RpcIdempotencyProfile; +import dev.caskeleton.grpc.policy.WaitForReadyPolicy; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcWaitForReadyValidatorTest { + + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + private static final GrpcDeadlineProfile TEN_SECONDS = + GrpcDeadlineProfile.of(Duration.ofSeconds(10)); + + private static GrpcMethodPolicy policyWith(WaitForReadyPolicy waitForReady) { + return new GrpcMethodPolicy( + GET, + RpcType.UNARY, + RpcIdempotencyProfile.READ_ONLY, + TEN_SECONDS, + waitForReady, + false, + 1024, + 1024); + } + + @Test + @DisplayName("wait-for-ready is off by default") + void offByDefault() { + assertThat(GrpcWaitForReadyProfile.disabled().policy()).isEqualTo(WaitForReadyPolicy.DISABLED); + assertThat( + GrpcWaitForReadyValidator.decide( + policyWith(WaitForReadyPolicy.DISABLED), + GrpcWaitForReadyProfile.disabled(), + new GrpcDeadlineBudget(Duration.ofSeconds(5), TEN_SECONDS)) + .queue()) + .isFalse(); + } + + @Test + @DisplayName("queueing without a deadline is refused rather than allowed to wait forever") + void queueingWithoutADeadlineIsRefused() { + assertThatThrownBy( + () -> + GrpcWaitForReadyValidator.decide( + policyWith(WaitForReadyPolicy.WORKER_OPT_IN), + GrpcWaitForReadyProfile.worker(Duration.ofSeconds(2)), + null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("queues forever"); + } + + @Test + @DisplayName("a queueing profile without a queue budget is refused") + void aQueueingProfileNeedsABudget() { + assertThatThrownBy( + () -> + new GrpcWaitForReadyProfile(WaitForReadyPolicy.WORKER_OPT_IN, Duration.ZERO, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("waits until the deadline"); + } + + @Test + @DisplayName("a user-synchronous path may only queue with an explicit approval") + void userSynchronousQueueingNeedsApproval() { + assertThatThrownBy( + () -> + new GrpcWaitForReadyProfile( + WaitForReadyPolicy.APPROVED_SYNCHRONOUS, Duration.ofSeconds(1), false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("explicit approval"); + assertThat( + GrpcWaitForReadyProfile.approvedSynchronous(Duration.ofSeconds(1)) + .approvedForUserSynchronous()) + .isTrue(); + } + + @Test + @DisplayName("a worker queues within the smaller of its budget and its remaining deadline") + void aWorkerQueuesWithinTheSmallerBound() { + GrpcWaitForReadyDecision generousBudget = + GrpcWaitForReadyValidator.decide( + policyWith(WaitForReadyPolicy.WORKER_OPT_IN), + GrpcWaitForReadyProfile.worker(Duration.ofSeconds(30)), + new GrpcDeadlineBudget(Duration.ofSeconds(5), TEN_SECONDS)); + GrpcWaitForReadyDecision tightBudget = + GrpcWaitForReadyValidator.decide( + policyWith(WaitForReadyPolicy.WORKER_OPT_IN), + GrpcWaitForReadyProfile.worker(Duration.ofSeconds(2)), + new GrpcDeadlineBudget(Duration.ofSeconds(5), TEN_SECONDS)); + + assertThat(generousBudget.maxQueueWait()).isEqualTo(Duration.ofSeconds(5)); + assertThat(tightBudget.maxQueueWait()).isEqualTo(Duration.ofSeconds(2)); + } + + @Test + @DisplayName("a method and a channel that disagree resolve to the stricter answer") + void disagreementResolvesToFailFast() { + GrpcWaitForReadyDecision decision = + GrpcWaitForReadyValidator.decide( + policyWith(WaitForReadyPolicy.DISABLED), + GrpcWaitForReadyProfile.worker(Duration.ofSeconds(2)), + new GrpcDeadlineBudget(Duration.ofSeconds(5), TEN_SECONDS)); + + assertThat(decision.queue()).isFalse(); + assertThat(decision.reason()).contains("stricter"); + } + + @Test + @DisplayName("no queueing happens once the deadline is spent") + void anExpiredDeadlineDoesNotQueue() { + GrpcWaitForReadyDecision decision = + GrpcWaitForReadyValidator.decide( + policyWith(WaitForReadyPolicy.WORKER_OPT_IN), + GrpcWaitForReadyProfile.worker(Duration.ofSeconds(2)), + new GrpcDeadlineBudget(Duration.ZERO, TEN_SECONDS)); + + assertThat(decision.queue()).isFalse(); + } + + @Test + @DisplayName("the queue allowance is carried separately from the call deadline") + void queueTimeIsMeasuredSeparately() { + GrpcWaitForReadyDecision decision = + GrpcWaitForReadyValidator.decide( + policyWith(WaitForReadyPolicy.WORKER_OPT_IN), + GrpcWaitForReadyProfile.worker(Duration.ofSeconds(2)), + new GrpcDeadlineBudget(Duration.ofSeconds(5), TEN_SECONDS)); + + assertThat(decision.maxQueueWait()).isEqualTo(Duration.ofSeconds(2)); + assertThatThrownBy( + () -> new GrpcWaitForReadyDecision(false, Duration.ofSeconds(1), "inconsistent")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/security/GrpcTlsProfileTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/security/GrpcTlsProfileTest.java new file mode 100644 index 00000000..dba6213d --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/security/GrpcTlsProfileTest.java @@ -0,0 +1,163 @@ +package dev.caskeleton.grpc.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class GrpcTlsProfileTest { + + @ParameterizedTest + @EnumSource( + value = GrpcTlsProfile.Environment.class, + names = {"DEV", "STAGE", "PROD"}) + @DisplayName("TLS is mandatory in every deployed environment") + void tlsIsMandatoryWhereItMatters(GrpcTlsProfile.Environment environment) { + assertThatThrownBy( + () -> new GrpcTlsProfile(environment, false, false, false, true, "trust", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("TLS is required"); + assertThatThrownBy(() -> GrpcTlsProfile.plaintextLocal(environment)) + .isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest + @EnumSource( + value = GrpcTlsProfile.Environment.class, + names = {"DEV", "STAGE", "PROD"}) + @DisplayName("trust-all and hostname verification off are refused outside local and test") + void trustAllAndHostnameVerificationOffAreRefused(GrpcTlsProfile.Environment environment) { + assertThatThrownBy( + () -> new GrpcTlsProfile(environment, true, false, true, true, "trust", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("trust-all is refused"); + assertThatThrownBy( + () -> new GrpcTlsProfile(environment, true, false, false, false, "trust", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("hostname verification"); + } + + @Test + @DisplayName("plaintext is available on LOCAL and TEST only, and must be asked for") + void plaintextIsAvailableOnlyLocally() { + assertThat(GrpcTlsProfile.plaintextLocal(GrpcTlsProfile.Environment.LOCAL).tlsEnabled()) + .isFalse(); + assertThat(GrpcTlsProfile.plaintextLocal(GrpcTlsProfile.Environment.TEST).tlsEnabled()) + .isFalse(); + assertThat(GrpcTlsProfile.Environment.LOCAL.tlsRequired()).isFalse(); + assertThat(GrpcTlsProfile.Environment.PROD.tlsRequired()).isTrue(); + } + + @Test + @DisplayName("mTLS needs both TLS and client key material") + void mutualTlsNeedsTlsAndKeyMaterial() { + assertThat( + GrpcTlsProfile.mutual(GrpcTlsProfile.Environment.PROD, "trust-bundle", "client-key") + .mutualTls()) + .isTrue(); + assertThatThrownBy( + () -> + new GrpcTlsProfile( + GrpcTlsProfile.Environment.PROD, true, true, false, true, "trust", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("client key material"); + assertThatThrownBy( + () -> + new GrpcTlsProfile( + GrpcTlsProfile.Environment.LOCAL, false, true, false, true, null, "key")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("mTLS without TLS"); + } + + @Test + @DisplayName("a token-bearing profile refuses plaintext and mTLS identity refuses one-way TLS") + void authenticationProfilesRefuseIncompatibleTransports() { + GrpcTlsProfile plaintext = GrpcTlsProfile.plaintextLocal(GrpcTlsProfile.Environment.LOCAL); + GrpcTlsProfile serverAuth = + GrpcTlsProfile.serverAuthenticated(GrpcTlsProfile.Environment.PROD, "trust-bundle"); + + assertThatThrownBy(() -> GrpcAuthenticationProfile.BEARER_TOKEN.requireCompatible(plaintext)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("hands it to anyone on the path"); + assertThatThrownBy(() -> GrpcAuthenticationProfile.MUTUAL_TLS.requireCompatible(serverAuth)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("requires mTLS"); + + GrpcAuthenticationProfile.JWT.requireCompatible(serverAuth); + GrpcAuthenticationProfile.MUTUAL_TLS.requireCompatible( + GrpcTlsProfile.mutual(GrpcTlsProfile.Environment.PROD, "trust", "key")); + } + + @Test + @DisplayName("every token profile is supplied per call rather than as a fixed header") + void tokenProfilesRequireCallCredentials() { + assertThat(GrpcAuthenticationProfile.BEARER_TOKEN.callCredentialsRequired()).isTrue(); + assertThat(GrpcAuthenticationProfile.JWT.callCredentialsRequired()).isTrue(); + assertThat(GrpcAuthenticationProfile.SERVICE_TOKEN.callCredentialsRequired()).isTrue(); + assertThat(GrpcAuthenticationProfile.MUTUAL_TLS.callCredentialsRequired()).isFalse(); + assertThat(GrpcAuthenticationProfile.NONE.callCredentialsRequired()).isFalse(); + } + + @Test + @DisplayName("a credential generation carries a reference, never the material") + void aCredentialGenerationCarriesOnlyAReference() { + Instant issued = Instant.parse("2026-08-30T10:00:00Z"); + GrpcCredentialGeneration generation = + new GrpcCredentialGeneration( + 1L, "vault://grpc/client-cert", issued, issued.plusSeconds(60)); + + assertThat(generation.validAt(issued.plusSeconds(30))).isTrue(); + assertThat(generation.validAt(issued.plusSeconds(60))).isFalse(); + assertThatThrownBy( + () -> new GrpcCredentialGeneration(0L, "vault://x", issued, issued.plusSeconds(1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new GrpcCredentialGeneration(1L, "vault://x", issued, issued)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("rotation activates the new generation and hands back the one to drain") + void rotationDrainsThePreviousGeneration() { + Instant now = Instant.parse("2026-08-30T10:00:00Z"); + GrpcCredentialGeneration first = + new GrpcCredentialGeneration(1L, "vault://v1", now, now.plusSeconds(3600)); + GrpcCredentialGeneration second = + new GrpcCredentialGeneration(2L, "vault://v2", now, now.plusSeconds(3600)); + GrpcCredentialRotationManager manager = + new GrpcCredentialRotationManager(first, Duration.ofSeconds(30)); + + GrpcCredentialRotationManager.RotationPlan plan = manager.rotate(second, now); + + assertThat(plan.activated()).isEqualTo(second); + assertThat(plan.requiresDrain()).isTrue(); + assertThat(plan.draining()).contains(first); + assertThat(manager.current()).isEqualTo(second); + assertThat(manager.drainExpired(now.plusSeconds(29))).isFalse(); + assertThat(manager.drainExpired(now.plusSeconds(30))).isTrue(); + + manager.completeDrain(); + assertThat(manager.draining()).isEmpty(); + } + + @Test + @DisplayName("re-applying the current generation is a no-op, and going backwards is refused") + void rotationIsIdempotentAndMonotonic() { + Instant now = Instant.parse("2026-08-30T10:00:00Z"); + GrpcCredentialGeneration second = + new GrpcCredentialGeneration(2L, "vault://v2", now, now.plusSeconds(3600)); + GrpcCredentialGeneration first = + new GrpcCredentialGeneration(1L, "vault://v1", now, now.plusSeconds(3600)); + GrpcCredentialRotationManager manager = + new GrpcCredentialRotationManager(second, Duration.ofSeconds(30)); + + assertThat(manager.rotate(second, now).requiresDrain()).isFalse(); + assertThatThrownBy(() -> manager.rotate(first, now)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not supersede"); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/streaming/GrpcFlowControlPolicyTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/streaming/GrpcFlowControlPolicyTest.java new file mode 100644 index 00000000..c3e7ee2d --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/streaming/GrpcFlowControlPolicyTest.java @@ -0,0 +1,98 @@ +package dev.caskeleton.grpc.streaming; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcFlowControlPolicyTest { + + @Test + @DisplayName("both a message bound and a byte bound are enforced") + void bothBoundsAreEnforced() { + GrpcFlowControlPolicy policy = + new GrpcFlowControlPolicy(4, 1024L, 3, GrpcSlowConsumerPolicy.TERMINATE); + + assertThat(policy.decide(4, 0L, 1L, true).action()) + .isEqualTo(GrpcFlowControlDecision.Action.TERMINATE); + assertThat(policy.decide(0, 1000L, 100L, true).action()) + .isEqualTo(GrpcFlowControlDecision.Action.TERMINATE); + } + + @Test + @DisplayName("a writer pauses when the transport is not ready, without terminating") + void anUnreadyTransportPausesRatherThanTerminates() { + GrpcFlowControlPolicy policy = GrpcFlowControlPolicy.stable(); + + GrpcFlowControlDecision decision = policy.decide(0, 0L, 128L, false); + + assertThat(decision.action()).isEqualTo(GrpcFlowControlDecision.Action.PAUSE); + assertThat(decision.mayProduce()).isFalse(); + assertThat(decision.reason()).contains("not ready"); + } + + @Test + @DisplayName("the high-water mark pauses production before the queue is full") + void theHighWaterMarkPausesEarly() { + GrpcFlowControlPolicy policy = + new GrpcFlowControlPolicy(10, 1_000_000L, 8, GrpcSlowConsumerPolicy.TERMINATE); + + assertThat(policy.decide(7, 0L, 1L, true).action()) + .isEqualTo(GrpcFlowControlDecision.Action.PROCEED); + assertThat(policy.decide(8, 0L, 1L, true).action()) + .isEqualTo(GrpcFlowControlDecision.Action.PAUSE); + } + + @Test + @DisplayName("the default for a slow consumer is termination, not silent loss") + void terminationIsTheDefaultForASlowConsumer() { + assertThat(GrpcFlowControlPolicy.stable().slowConsumerPolicy()) + .isEqualTo(GrpcSlowConsumerPolicy.TERMINATE); + assertThat(GrpcSlowConsumerPolicy.TERMINATE.tolerable()).isFalse(); + assertThat(GrpcSlowConsumerPolicy.DROP_OLDEST.tolerable()).isTrue(); + } + + @Test + @DisplayName("a lossy profile drops the oldest message instead of ending the stream") + void aLossyProfileDropsRatherThanTerminates() { + GrpcFlowControlPolicy lossy = + new GrpcFlowControlPolicy(2, 1024L, 1, GrpcSlowConsumerPolicy.DROP_OLDEST); + + assertThat(lossy.decide(2, 0L, 1L, true).action()) + .isEqualTo(GrpcFlowControlDecision.Action.DROP_OLDEST); + } + + @Test + @DisplayName("a high-water mark outside the queue bound is refused") + void anIncoherentHighWaterMarkIsRefused() { + assertThatThrownBy( + () -> new GrpcFlowControlPolicy(4, 1024L, 5, GrpcSlowConsumerPolicy.TERMINATE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("high-water mark"); + } + + @Test + @DisplayName("stream admission bounds the total and the per-caller share") + void streamAdmissionBoundsTotalAndPerCaller() { + GrpcStreamAdmission admission = new GrpcStreamAdmission(3, 2); + + assertThat(admission.tryAdmit("tenant-1")).isTrue(); + assertThat(admission.tryAdmit("tenant-1")).isTrue(); + assertThat(admission.tryAdmit("tenant-1")).isFalse(); + assertThat(admission.tryAdmit("tenant-2")).isTrue(); + assertThat(admission.tryAdmit("tenant-2")).isFalse(); + assertThat(admission.openStreams()).isEqualTo(3); + assertThat(admission.openStreamsFor("tenant-1")).isEqualTo(2); + + admission.release("tenant-1"); + assertThat(admission.tryAdmit("tenant-1")).isTrue(); + } + + @Test + @DisplayName("a per-caller bound above the total bound is refused") + void anIncoherentAdmissionBoundIsRefused() { + assertThatThrownBy(() -> new GrpcStreamAdmission(2, 5)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/streaming/GrpcResumeTokenCodecTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/streaming/GrpcResumeTokenCodecTest.java new file mode 100644 index 00000000..4297f2ac --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/streaming/GrpcResumeTokenCodecTest.java @@ -0,0 +1,169 @@ +package dev.caskeleton.grpc.streaming; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcResumeTokenCodecTest { + + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + private static final byte[] KEY_ONE = "key-one-material".getBytes(StandardCharsets.UTF_8); + private static final byte[] KEY_TWO = "key-two-material".getBytes(StandardCharsets.UTF_8); + + private final GrpcResumeTokenCodec codec = + new GrpcResumeTokenCodec(Map.of("k1", KEY_ONE, "k2", KEY_TWO), "k2"); + + private static GrpcResumeToken token(String keyId, Instant expiresAt) { + return new GrpcResumeToken( + GrpcResumeToken.CURRENT_VERSION, + "stream-1", + 1L, + "snapshot-7", + 41L, + "tenant-1.actor-1", + "filter-abc", + expiresAt, + keyId); + } + + @Test + @DisplayName("a token round-trips through encoding and verification") + void aTokenRoundTrips() { + GrpcResumeToken original = token("k2", NOW.plusSeconds(600)); + + Optional decoded = codec.decode(codec.encode(original)); + + assertThat(decoded).contains(original); + } + + @Test + @DisplayName("a tampered token does not verify") + void aTamperedTokenDoesNotVerify() { + String encoded = codec.encode(token("k2", NOW.plusSeconds(600))); + String tampered = encoded.substring(0, encoded.length() - 2) + "AA"; + + assertThat(codec.decode(tampered)).isEmpty(); + } + + @Test + @DisplayName("an unknown key id is refused rather than retried with the active key") + void anUnknownKeyIdIsRefused() { + GrpcResumeTokenCodec other = new GrpcResumeTokenCodec(Map.of("k9", KEY_ONE), "k9"); + String signedElsewhere = other.encode(token("k9", NOW.plusSeconds(600))); + + assertThat(codec.decode(signedElsewhere)).isEmpty(); + } + + @Test + @DisplayName( + "a superseded key still verifies, so rotation does not invalidate everything at once") + void asSupersededKeyStillVerifies() { + String signedWithOldKey = codec.encode(token("k1", NOW.plusSeconds(600))); + + assertThat(codec.decode(signedWithOldKey)).isPresent(); + assertThat(codec.activeKeyId()).isEqualTo("k2"); + } + + @Test + @DisplayName("malformed input is rejected without distinguishing why") + void malformedInputIsRejected() { + assertThat(codec.decode(null)).isEmpty(); + assertThat(codec.decode("")).isEmpty(); + assertThat(codec.decode("not-base64!!")).isEmpty(); + } + + @Test + @DisplayName("a token expires, and an expired one is refused") + void anExpiredTokenIsRefused() { + GrpcStreamGapDetector detector = new GrpcStreamGapDetector(0L); + + GrpcResumeDecision decision = + detector.evaluate( + token("k2", NOW.minusSeconds(1)), "tenant-1.actor-1", "filter-abc", "snapshot-7", NOW); + + assertThat(decision.verdict()).isEqualTo(GrpcResumeDecision.Verdict.TOKEN_REJECTED); + assertThat(decision.reason()).contains("expired"); + } + + @Test + @DisplayName("a token issued for another caller or filter is refused") + void aTokenForAnotherCallerOrFilterIsRefused() { + GrpcStreamGapDetector detector = new GrpcStreamGapDetector(0L); + GrpcResumeToken valid = token("k2", NOW.plusSeconds(600)); + + assertThat( + detector.evaluate(valid, "tenant-2.actor-9", "filter-abc", "snapshot-7", NOW).verdict()) + .isEqualTo(GrpcResumeDecision.Verdict.TOKEN_REJECTED); + assertThat( + detector + .evaluate(valid, "tenant-1.actor-1", "filter-other", "snapshot-7", NOW) + .verdict()) + .isEqualTo(GrpcResumeDecision.Verdict.TOKEN_REJECTED); + } + + @Test + @DisplayName("a moved snapshot requires a full resync rather than a resume") + void aMovedSnapshotRequiresAFullResync() { + GrpcStreamGapDetector detector = new GrpcStreamGapDetector(0L); + + GrpcResumeDecision decision = + detector.evaluate( + token("k2", NOW.plusSeconds(600)), "tenant-1.actor-1", "filter-abc", "snapshot-8", NOW); + + assertThat(decision.verdict()).isEqualTo(GrpcResumeDecision.Verdict.FULL_RESYNC_REQUIRED); + } + + @Test + @DisplayName("a cursor before the retained history requires a full resync, not a silent skip") + void aCursorBeforeRetainedHistoryRequiresAFullResync() { + GrpcStreamGapDetector detector = new GrpcStreamGapDetector(100L); + + GrpcResumeDecision decision = + detector.evaluate( + token("k2", NOW.plusSeconds(600)), "tenant-1.actor-1", "filter-abc", "snapshot-7", NOW); + + assertThat(decision.verdict()).isEqualTo(GrpcResumeDecision.Verdict.FULL_RESYNC_REQUIRED); + assertThat(decision.reason()).contains("silently skip"); + } + + @Test + @DisplayName("a valid, replayable token resumes from its sequence") + void aValidTokenResumes() { + GrpcStreamGapDetector detector = new GrpcStreamGapDetector(0L); + + GrpcResumeDecision decision = + detector.evaluate( + token("k2", NOW.plusSeconds(600)), "tenant-1.actor-1", "filter-abc", "snapshot-7", NOW); + + assertThat(decision.verdict()).isEqualTo(GrpcResumeDecision.Verdict.RESUME); + assertThat(decision.resumeFromSequence()).contains(41L); + } + + @Test + @DisplayName("gaps and duplicates are distinguished, and a duplicate does not advance the cursor") + void gapsAndDuplicatesAreDistinguished() { + GrpcStreamGapDetector detector = new GrpcStreamGapDetector(0L); + + assertThat(detector.observe(1L)).isEqualTo(GrpcStreamGapDetector.Observation.IN_ORDER); + assertThat(detector.observe(2L)).isEqualTo(GrpcStreamGapDetector.Observation.IN_ORDER); + assertThat(detector.observe(2L)).isEqualTo(GrpcStreamGapDetector.Observation.DUPLICATE); + assertThat(detector.lastObservedSequence()).contains(2L); + assertThat(detector.observe(5L)).isEqualTo(GrpcStreamGapDetector.Observation.GAP); + assertThat(detector.lastObservedSequence()).contains(5L); + } + + @Test + @DisplayName("a token field may not contain the separator that would forge another field") + void tokenFieldsRejectTheSeparator() { + assertThatThrownBy( + () -> new GrpcResumeToken(1, "stream|1", 1L, "snap", 1L, "caller", "filter", NOW, "k1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not contain"); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/streaming/GrpcSerializedStreamWriterTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/streaming/GrpcSerializedStreamWriterTest.java new file mode 100644 index 00000000..a897c56e --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/streaming/GrpcSerializedStreamWriterTest.java @@ -0,0 +1,141 @@ +package dev.caskeleton.grpc.streaming; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcSerializedStreamWriterTest { + + private static final GrpcStreamId STREAM = new GrpcStreamId("stream-1", 1L); + + private final List> sent = + Collections.synchronizedList(new ArrayList<>()); + + private GrpcSerializedStreamWriter writer( + GrpcFlowControlPolicy policy, long messageSize) { + return new GrpcSerializedStreamWriter<>( + STREAM, GrpcStreamSequence.forNewStream(STREAM), policy, sent::add, () -> messageSize); + } + + @Test + @DisplayName("a queued message is ACCEPTED, which is not a claim that it was delivered") + void queuedIsNotDelivered() { + GrpcSerializedStreamWriter writer = writer(GrpcFlowControlPolicy.stable(), 16L); + + GrpcStreamWriteResult result = + writer.write(GrpcStreamEnvelope.Kind.LIVE, "row", "snapshot-1", null, true); + + assertThat(result).isEqualTo(GrpcStreamWriteResult.ACCEPTED); + assertThat(result.queued()).isTrue(); + assertThat(sent).isEmpty(); + + assertThat(writer.flush()).containsExactly(1L); + assertThat(sent).hasSize(1); + } + + @Test + @DisplayName("concurrent producers never touch the transport; only flush does") + void concurrentProducersDoNotTouchTheTransport() throws Exception { + GrpcSerializedStreamWriter writer = + writer( + new GrpcFlowControlPolicy(1000, 1_000_000L, 999, GrpcSlowConsumerPolicy.TERMINATE), 1L); + int producers = 8; + int perProducer = 50; + ExecutorService pool = Executors.newFixedThreadPool(producers); + CountDownLatch start = new CountDownLatch(1); + + try { + for (int producer = 0; producer < producers; producer++) { + pool.execute( + () -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + for (int index = 0; index < perProducer; index++) { + writer.write(GrpcStreamEnvelope.Kind.LIVE, "row", null, null, true); + } + }); + } + start.countDown(); + pool.shutdown(); + assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue(); + } finally { + pool.shutdownNow(); + } + + assertThat(sent).isEmpty(); + List flushed = writer.flush(); + assertThat(flushed).hasSize(producers * perProducer); + assertThat(flushed).isSorted(); + assertThat(flushed).doesNotHaveDuplicates(); + } + + @Test + @DisplayName("an overflow terminates the stream rather than dropping silently") + void overflowTerminates() { + GrpcSerializedStreamWriter writer = + writer(new GrpcFlowControlPolicy(1, 1024L, 1, GrpcSlowConsumerPolicy.TERMINATE), 8L); + writer.write(GrpcStreamEnvelope.Kind.LIVE, "first", null, null, true); + + GrpcStreamWriteResult overflow = + writer.write(GrpcStreamEnvelope.Kind.LIVE, "second", null, null, true); + + assertThat(overflow).isEqualTo(GrpcStreamWriteResult.OVERFLOW_TERMINATING); + assertThat(writer.state()).isEqualTo(GrpcStreamWriterState.DRAINING); + assertThat(writer.write(GrpcStreamEnvelope.Kind.LIVE, "third", null, null, true)) + .isEqualTo(GrpcStreamWriteResult.REJECTED_CLOSED); + } + + @Test + @DisplayName("a lossy profile drops the oldest and reports it rather than hiding it") + void aLossyProfileReportsWhatItDropped() { + GrpcSerializedStreamWriter writer = + writer(new GrpcFlowControlPolicy(1, 1024L, 1, GrpcSlowConsumerPolicy.DROP_OLDEST), 8L); + writer.write(GrpcStreamEnvelope.Kind.LIVE, "first", null, null, true); + + assertThat(writer.write(GrpcStreamEnvelope.Kind.LIVE, "second", null, null, true)) + .isEqualTo(GrpcStreamWriteResult.DROPPED); + assertThat(writer.droppedMessages()).isEqualTo(1); + assertThat(writer.queuedMessages()).isEqualTo(1); + } + + @Test + @DisplayName("the terminal signal is sent exactly once") + void theTerminalSignalIsSentOnce() { + GrpcSerializedStreamWriter writer = writer(GrpcFlowControlPolicy.stable(), 8L); + + assertThat(writer.terminate(GrpcStreamTerminationReason.COMPLETED, null)).isTrue(); + assertThat(writer.terminate(GrpcStreamTerminationReason.SERVER_DRAIN, null)).isFalse(); + + assertThat(sent).hasSize(1); + assertThat(sent.get(0).terminationReason()).contains(GrpcStreamTerminationReason.COMPLETED); + assertThat(writer.state()).isEqualTo(GrpcStreamWriterState.TERMINATED); + } + + @Test + @DisplayName("cancelling discards the queue and keeps the last flushed position as evidence") + void cancellingKeepsTheLastFlushedPositionAsEvidence() { + GrpcSerializedStreamWriter writer = writer(GrpcFlowControlPolicy.stable(), 8L); + writer.write(GrpcStreamEnvelope.Kind.LIVE, "first", null, null, true); + writer.flush(); + writer.write(GrpcStreamEnvelope.Kind.LIVE, "second", null, null, true); + + long lastFlushed = writer.discardQueued(); + + assertThat(lastFlushed).isEqualTo(1L); + assertThat(writer.lastFlushedSequence()).contains(1L); + assertThat(writer.queuedMessages()).isZero(); + assertThat(sent).hasSize(1); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/streaming/GrpcStreamEnvelopeTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/streaming/GrpcStreamEnvelopeTest.java new file mode 100644 index 00000000..9788b7f4 --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/streaming/GrpcStreamEnvelopeTest.java @@ -0,0 +1,128 @@ +package dev.caskeleton.grpc.streaming; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcStreamEnvelopeTest { + + private static final GrpcStreamId STREAM = new GrpcStreamId("stream-1", 1L); + + @Test + @DisplayName("a sequence is monotonic within one generation and continues across a resume") + void sequencesAreMonotonicWithinAGeneration() { + GrpcStreamSequence fresh = GrpcStreamSequence.forNewStream(STREAM); + + assertThat(fresh.next()).isEqualTo(1L); + assertThat(fresh.next()).isEqualTo(2L); + assertThat(fresh.lastIssued()).isEqualTo(2L); + + GrpcStreamSequence resumed = + new GrpcStreamSequence(STREAM.nextGeneration(), fresh.lastIssued()); + assertThat(resumed.next()).isEqualTo(3L); + assertThat(resumed.streamId().generation()).isEqualTo(2L); + } + + @Test + @DisplayName("a resumed stream keeps its identity and advances its generation") + void aResumeAdvancesTheGeneration() { + GrpcStreamId next = STREAM.nextGeneration(); + + assertThat(next.sameStreamAs(STREAM)).isTrue(); + assertThat(next.generation()).isEqualTo(2L); + assertThat(GrpcStreamId.newStream().generation()).isEqualTo(1L); + } + + @Test + @DisplayName("the snapshot version and the resume cursor are separate fields") + void snapshotVersionAndResumeCursorAreSeparate() { + GrpcStreamEnvelope envelope = + GrpcStreamEnvelope.message( + STREAM, 1L, GrpcStreamEnvelope.Kind.SNAPSHOT, "row", "snapshot-7", "token-abc"); + + assertThat(envelope.snapshotVersion()).contains("snapshot-7"); + assertThat(envelope.resumeToken()).contains("token-abc"); + } + + @Test + @DisplayName("only payload-carrying kinds carry a payload") + void kindAndPayloadMustAgree() { + assertThatThrownBy( + () -> + new GrpcStreamEnvelope<>( + STREAM, + 1L, + GrpcStreamEnvelope.Kind.HEARTBEAT, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.of("row"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new GrpcStreamEnvelope( + STREAM, + 1L, + GrpcStreamEnvelope.Kind.LIVE, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("only a terminal envelope carries a termination reason") + void onlyTerminalEnvelopesCarryAReason() { + GrpcStreamEnvelope terminal = + GrpcStreamEnvelope.termination( + STREAM, 9L, GrpcStreamTerminationReason.SERVER_DRAIN, "token-abc"); + + assertThat(terminal.kind().terminal()).isTrue(); + assertThat(terminal.terminationReason()).contains(GrpcStreamTerminationReason.SERVER_DRAIN); + assertThatThrownBy( + () -> + new GrpcStreamEnvelope( + STREAM, + 1L, + GrpcStreamEnvelope.Kind.HEARTBEAT, + Optional.empty(), + Optional.empty(), + Optional.of(GrpcStreamTerminationReason.COMPLETED), + Optional.empty())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("sequences are one-based") + void sequencesAreOneBased() { + assertThat(GrpcStreamEnvelope.heartbeat(STREAM, 1L).sequence()).isEqualTo(1L); + assertThatThrownBy(() -> GrpcStreamEnvelope.heartbeat(STREAM, 0L)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a profile that buffers live events until the snapshot completes is the default") + void theDefaultProfileBuffersLiveEventsUntilTheSnapshotCompletes() { + GrpcStreamProfile profile = GrpcStreamProfile.snapshotThenLive(); + + assertThat(profile.snapshotFirst()).isTrue(); + assertThat(profile.liveEventsBeforeSnapshotComplete()).isFalse(); + assertThat(profile.resumable()).isTrue(); + assertThatThrownBy(() -> new GrpcStreamProfile(false, true, true, 10, 1024L)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a termination reason says whether to reconnect and whether the position survives") + void terminationReasonsSayWhatToDoNext() { + assertThat(GrpcStreamTerminationReason.IDLE_TIMEOUT.reconnectExpected()).isTrue(); + assertThat(GrpcStreamTerminationReason.IDLE_TIMEOUT.resumable()).isTrue(); + assertThat(GrpcStreamTerminationReason.SLOW_CONSUMER.resumable()).isFalse(); + assertThat(GrpcStreamTerminationReason.FULL_RESYNC_REQUIRED.resumable()).isFalse(); + assertThat(GrpcStreamTerminationReason.CLIENT_CANCELLED.reconnectExpected()).isFalse(); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/streaming/GrpcStreamLifetimePolicyTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/streaming/GrpcStreamLifetimePolicyTest.java new file mode 100644 index 00000000..8d1f9725 --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/streaming/GrpcStreamLifetimePolicyTest.java @@ -0,0 +1,139 @@ +package dev.caskeleton.grpc.streaming; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcStreamLifetimePolicyTest { + + private static final Instant START = Instant.parse("2026-08-30T10:00:00Z"); + + @Test + @DisplayName("the four clocks are separate and ordered coherently") + void theFourClocksAreSeparate() { + GrpcStreamLifetimePolicy policy = GrpcStreamLifetimePolicy.longLived(); + + assertThat(policy.heartbeatInterval()).isLessThan(policy.idleTimeout()); + assertThat(policy.idleTimeout()).isLessThan(policy.maxDuration()); + assertThat(policy.setupDeadline()).isLessThan(policy.maxDuration()); + } + + @Test + @DisplayName("a heartbeat at or above the idle timeout would declare a healthy stream idle") + void aHeartbeatAtTheIdleTimeoutIsRefused() { + assertThatThrownBy( + () -> + new GrpcStreamLifetimePolicy( + Duration.ofSeconds(5), + Duration.ofSeconds(30), + Duration.ofMinutes(10), + Duration.ofSeconds(30), + true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("declared idle"); + } + + @Test + @DisplayName("an idle timeout longer than the max duration can never fire") + void anIdleTimeoutThatCanNeverFireIsRefused() { + assertThatThrownBy( + () -> + new GrpcStreamLifetimePolicy( + Duration.ofSeconds(5), + Duration.ofMinutes(20), + Duration.ofMinutes(10), + Duration.ofSeconds(10), + true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("can never fire"); + } + + @Test + @DisplayName("credential expiry ends a stream before any lifetime clock does") + void credentialExpiryOutranksTheLifetimeClocks() { + GrpcStreamLifecycleCoordinator coordinator = + new GrpcStreamLifecycleCoordinator(GrpcStreamLifetimePolicy.longLived(), START); + coordinator.signalDrain(); + + assertThat(coordinator.terminationDue(START.plusSeconds(10), START.plusSeconds(5))) + .contains(GrpcStreamTerminationReason.CREDENTIAL_EXPIRED); + } + + @Test + @DisplayName("a drain ends a stream before its own timers fire") + void aDrainOutranksTheTimers() { + GrpcStreamLifecycleCoordinator coordinator = + new GrpcStreamLifecycleCoordinator(GrpcStreamLifetimePolicy.longLived(), START); + coordinator.signalDrain(); + + assertThat(coordinator.terminationDue(START.plusSeconds(1), null)) + .contains(GrpcStreamTerminationReason.SERVER_DRAIN); + } + + @Test + @DisplayName("an idle stream ends with IDLE_TIMEOUT and a long-lived one with MAX_DURATION") + void idleAndMaxDurationAreDistinguished() { + GrpcStreamLifecycleCoordinator coordinator = + new GrpcStreamLifecycleCoordinator(GrpcStreamLifetimePolicy.longLived(), START); + + assertThat(coordinator.terminationDue(START.plusSeconds(121), null)) + .contains(GrpcStreamTerminationReason.IDLE_TIMEOUT); + + GrpcStreamLifecycleCoordinator busy = + new GrpcStreamLifecycleCoordinator(GrpcStreamLifetimePolicy.longLived(), START); + busy.recordActivity(START.plusSeconds(3600)); + assertThat(busy.terminationDue(START.plusSeconds(3601), null)) + .contains(GrpcStreamTerminationReason.MAX_DURATION); + } + + @Test + @DisplayName("a live stream is not terminated") + void aLiveStreamKeepsRunning() { + GrpcStreamLifecycleCoordinator coordinator = + new GrpcStreamLifecycleCoordinator(GrpcStreamLifetimePolicy.longLived(), START); + coordinator.recordActivity(START.plusSeconds(30)); + + assertThat(coordinator.terminationDue(START.plusSeconds(60), START.plusSeconds(3600))) + .isEmpty(); + } + + @Test + @DisplayName("a heartbeat becomes due after its interval and is postponed by real activity") + void heartbeatsArePostponedByRealActivity() { + GrpcStreamLifecycleCoordinator coordinator = + new GrpcStreamLifecycleCoordinator(GrpcStreamLifetimePolicy.longLived(), START); + + assertThat(coordinator.heartbeatDue(START.plusSeconds(29))).isFalse(); + assertThat(coordinator.heartbeatDue(START.plusSeconds(30))).isTrue(); + + coordinator.recordActivity(START.plusSeconds(30)); + assertThat(coordinator.heartbeatDue(START.plusSeconds(31))).isFalse(); + } + + @Test + @DisplayName("a stream that never produces its first message trips the setup deadline") + void theSetupDeadlineCoversTheFirstMessage() { + GrpcStreamLifecycleCoordinator coordinator = + new GrpcStreamLifecycleCoordinator(GrpcStreamLifetimePolicy.longLived(), START); + + assertThat(coordinator.setupDeadlineExceeded(START.plusSeconds(11), false)).isTrue(); + assertThat(coordinator.setupDeadlineExceeded(START.plusSeconds(11), true)).isFalse(); + } + + @Test + @DisplayName("only a reason a reconnect helps with makes a stream a reconnect candidate") + void onlySomeReasonsMakeAStreamAReconnectCandidate() { + GrpcStreamLifecycleCoordinator coordinator = + new GrpcStreamLifecycleCoordinator(GrpcStreamLifetimePolicy.longLived(), START); + + assertThat(coordinator.reconnectCandidate(GrpcStreamTerminationReason.IDLE_TIMEOUT)).isTrue(); + assertThat(coordinator.reconnectCandidate(GrpcStreamTerminationReason.CLIENT_CANCELLED)) + .isFalse(); + assertThat(coordinator.reconnectCandidate(GrpcStreamTerminationReason.CREDENTIAL_EXPIRED)) + .isFalse(); + } +} diff --git a/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/validation/ProtovalidateGrpcInterceptorTest.java b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/validation/ProtovalidateGrpcInterceptorTest.java new file mode 100644 index 00000000..ad306913 --- /dev/null +++ b/src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/validation/ProtovalidateGrpcInterceptorTest.java @@ -0,0 +1,240 @@ +package dev.caskeleton.grpc.validation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.Status; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ProtovalidateGrpcInterceptorTest { + + private static final String FULL_METHOD = "hyeonworks.document.v1.DocumentService/CreateDocument"; + + /** A UTF-8 marshaller, so the descriptor needs no generated message type. */ + private static final MethodDescriptor.Marshaller UTF8 = + new MethodDescriptor.Marshaller<>() { + @Override + public InputStream stream(String value) { + return new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public String parse(InputStream stream) { + try { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + }; + + private static final MethodDescriptor DESCRIPTOR = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName(FULL_METHOD) + .setRequestMarshaller(UTF8) + .setResponseMarshaller(UTF8) + .build(); + + private static GrpcTransportValidator titleValidator() { + return GrpcTransportValidator.builder() + .rule( + "document.title", + GrpcTransportValidator.Kind.LENGTH, + "LENGTH_EXCEEDED", + title -> title.length() <= 10) + .rule( + "document.title", + GrpcTransportValidator.Kind.FORMAT, + "MUST_NOT_BE_BLANK", + title -> !title.isBlank()) + .build(); + } + + /** Records what the interceptor did to the call, without a transport. */ + private static final class RecordingServerCall extends ServerCall { + private Status closedStatus; + private Metadata closedTrailers; + + @Override + public void request(int numMessages) { + // Nothing to pull from; the test feeds messages to the listener directly. + } + + @Override + public void sendHeaders(Metadata headers) { + throw new UnsupportedOperationException("this test never sends a response"); + } + + @Override + public void sendMessage(String message) { + throw new UnsupportedOperationException("this test never sends a response"); + } + + @Override + public void close(Status status, Metadata trailers) { + this.closedStatus = status; + this.closedTrailers = trailers; + } + + @Override + public boolean isCancelled() { + return false; + } + + @Override + public MethodDescriptor getMethodDescriptor() { + return DESCRIPTOR; + } + } + + /** Counts the messages that reached the handler behind the interceptor. */ + private static final class RecordingHandler implements ServerCallHandler { + private final List delivered = new ArrayList<>(); + private final AtomicInteger halfCloses = new AtomicInteger(); + + @Override + public ServerCall.Listener startCall( + ServerCall call, Metadata headers) { + return new ServerCall.Listener<>() { + @Override + public void onMessage(String message) { + delivered.add(message); + } + + @Override + public void onHalfClose() { + halfCloses.incrementAndGet(); + } + }; + } + } + + @Test + @DisplayName("a rule is a predicate over the message, so it cannot reach a database or a caller") + void aRuleSeesOnlyTheMessage() { + GrpcTransportValidator validator = titleValidator(); + + assertThat(validator.kinds()) + .containsExactlyInAnyOrder( + GrpcTransportValidator.Kind.LENGTH, GrpcTransportValidator.Kind.FORMAT); + assertThat(GrpcTransportValidator.Kind.values()) + .containsExactly( + GrpcTransportValidator.Kind.LENGTH, + GrpcTransportValidator.Kind.RANGE, + GrpcTransportValidator.Kind.COLLECTION_COUNT, + GrpcTransportValidator.Kind.FORMAT, + GrpcTransportValidator.Kind.CROSS_FIELD); + } + + @Test + @DisplayName("a validator with no rules is refused rather than silently accepting everything") + void anEmptyValidatorIsRefused() { + assertThatThrownBy(() -> GrpcTransportValidator.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("accepts everything"); + } + + @Test + @DisplayName("a duplicate rule on the same field and reason is refused") + void duplicateRulesAreRefused() { + GrpcTransportValidator.Builder builder = + GrpcTransportValidator.builder() + .rule("t", GrpcTransportValidator.Kind.LENGTH, "TOO_LONG", value -> true); + + assertThatThrownBy( + () -> builder.rule("t", GrpcTransportValidator.Kind.LENGTH, "TOO_LONG", value -> true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("duplicate validation rule"); + } + + @Test + @DisplayName("a valid message reaches the handler untouched") + void validMessagesReachTheHandler() { + ProtovalidateGrpcInterceptor interceptor = + new ProtovalidateGrpcInterceptor(Map.of(FULL_METHOD, titleValidator())); + RecordingServerCall call = new RecordingServerCall(); + RecordingHandler handler = new RecordingHandler(); + + ServerCall.Listener listener = interceptor.interceptCall(call, new Metadata(), handler); + listener.onMessage("ok"); + listener.onHalfClose(); + + assertThat(handler.delivered).containsExactly("ok"); + assertThat(handler.halfCloses).hasValue(1); + assertThat(call.closedStatus).isNull(); + } + + @Test + @DisplayName("an invalid message is closed as INVALID_ARGUMENT and never reaches the handler") + void invalidMessagesAreRefusedBeforeTheUseCase() { + ProtovalidateGrpcInterceptor interceptor = + new ProtovalidateGrpcInterceptor(Map.of(FULL_METHOD, titleValidator())); + RecordingServerCall call = new RecordingServerCall(); + RecordingHandler handler = new RecordingHandler(); + + ServerCall.Listener listener = interceptor.interceptCall(call, new Metadata(), handler); + listener.onMessage("this title is far too long to be accepted"); + listener.onHalfClose(); + + assertThat(handler.delivered).isEmpty(); + assertThat(handler.halfCloses).hasValue(0); + assertThat(call.closedStatus.getCode()).isEqualTo(Status.Code.INVALID_ARGUMENT); + assertThat(call.closedTrailers.get(ProtovalidateGrpcInterceptor.VIOLATIONS_KEY)) + .isEqualTo("document.title: LENGTH_EXCEEDED"); + } + + @Test + @DisplayName("the rejected value never appears in the status or the trailers") + void theRejectedValueIsNeverEchoed() { + ProtovalidateGrpcInterceptor interceptor = + new ProtovalidateGrpcInterceptor(Map.of(FULL_METHOD, titleValidator())); + RecordingServerCall call = new RecordingServerCall(); + String secret = "national-id-900101-1234567"; + + ServerCall.Listener listener = + interceptor.interceptCall(call, new Metadata(), new RecordingHandler()); + listener.onMessage(secret); + + assertThat(call.closedStatus.getDescription()).doesNotContain(secret); + assertThat(call.closedTrailers.get(ProtovalidateGrpcInterceptor.VIOLATIONS_KEY)) + .doesNotContain(secret); + } + + @Test + @DisplayName("a method with no registered validator is passed through rather than refused") + void unregisteredMethodsPassThrough() { + ProtovalidateGrpcInterceptor interceptor = new ProtovalidateGrpcInterceptor(Map.of()); + RecordingHandler handler = new RecordingHandler(); + + ServerCall.Listener listener = + interceptor.interceptCall(new RecordingServerCall(), new Metadata(), handler); + listener.onMessage("anything"); + + assertThat(handler.delivered).containsExactly("anything"); + } + + @Test + @DisplayName("a violation carries a field and a stable reason, and nothing else") + void aViolationCarriesNoValue() { + GrpcValidationViolation violation = GrpcValidationViolation.of("document.title", "TOO_LONG"); + + assertThat(violation.describe()).isEqualTo("document.title: TOO_LONG"); + assertThatThrownBy(() -> GrpcValidationViolation.of("document.title", " ")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/grpc/grpc-proto-contract/build.gradle b/src/grpc/grpc-proto-contract/build.gradle new file mode 100644 index 00000000..a861009d --- /dev/null +++ b/src/grpc/grpc-proto-contract/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'java-library' + +// Schema source of truth: the `.proto` files plus the rule engine that judges them. +// +// No protobuf plugin and no protoc invocation here — see +// docs/superpowers/specs/2026-08-30-grpc-platform-adaptation-design.md D6. The validator reads +// `.proto` text and enforces the Stable style manifest (proto3 + explicit optional, package +// versioning, reserved history, enum zero suffix, WKT allowlist), which is the invariant the plan +// owns; running protoc is a separate, gated decision. +dependencies { + api project(':grpc:grpc-core-api') +} diff --git a/src/grpc/grpc-proto-contract/gradle.lockfile b/src/grpc/grpc-proto-contract/gradle.lockfile new file mode 100644 index 00000000..e2c95854 --- /dev/null +++ b/src/grpc/grpc-proto-contract/gradle.lockfile @@ -0,0 +1,84 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty=compileClasspath,runtimeClasspath diff --git a/src/grpc/grpc-proto-contract/src/main/java/dev/caskeleton/grpc/contract/GrpcProtoContractValidator.java b/src/grpc/grpc-proto-contract/src/main/java/dev/caskeleton/grpc/contract/GrpcProtoContractValidator.java new file mode 100644 index 00000000..94dd6318 --- /dev/null +++ b/src/grpc/grpc-proto-contract/src/main/java/dev/caskeleton/grpc/contract/GrpcProtoContractValidator.java @@ -0,0 +1,447 @@ +package dev.caskeleton.grpc.contract; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Judges committed {@code .proto} sources against {@link GrpcProtoStyleManifest} and the schema's + * own removal history. + * + *

A line scanner, not a Protobuf parser, and that is a deliberate limit rather than a shortcut. + * Everything this checks is a property of the source text a reviewer reads — the syntax line, the + * package, the two Java options, the zero enum value, the {@code reserved} declarations, the + * imports, the {@code map} fields, explicit presence. Semantics that need a compiled descriptor + * belong to {@code grpc-codegen}'s descriptor artifact, which is where a real {@code protoc} run + * would be consumed. + * + *

The removal history is an input rather than something inferred, because it cannot be inferred: + * a field that is simply gone from the current source is indistinguishable from one that never + * existed. Recording removals and checking them against {@code reserved} is the only way the "do + * not reuse a field number" rule survives the commit that deletes the field. + */ +public final class GrpcProtoContractValidator { + + /** Rule ids, so a build log and a review checklist use the same names. */ + public static final String RULE_PROTO3_SYNTAX = "PROTO3_SYNTAX"; + + /** The package must be {@code organization.domain.vMAJOR}. */ + public static final String RULE_PACKAGE_VERSIONED = "PACKAGE_VERSIONED"; + + /** {@code option java_multiple_files = true} must be present. */ + public static final String RULE_JAVA_MULTIPLE_FILES = "JAVA_MULTIPLE_FILES"; + + /** The generated Java package must not collide with a hand-written one. */ + public static final String RULE_JAVA_PACKAGE_SEPARATE = "JAVA_PACKAGE_SEPARATE"; + + /** An enum's zero value must end in {@code _UNSPECIFIED}. */ + public static final String RULE_ENUM_ZERO_UNSPECIFIED = "ENUM_ZERO_UNSPECIFIED"; + + /** A removed field number or name must be {@code reserved}. */ + public static final String RULE_RESERVED_HISTORY = "RESERVED_HISTORY"; + + /** A {@code google.protobuf} import must be on the allowlist. */ + public static final String RULE_WELL_KNOWN_TYPE_ALLOWLIST = "WELL_KNOWN_TYPE_ALLOWLIST"; + + /** A {@code map} field must be on the allowlist. */ + public static final String RULE_MAP_ALLOWLIST = "MAP_ALLOWLIST"; + + /** A field the manifest marks presence-required must be declared {@code optional}. */ + public static final String RULE_EXPLICIT_PRESENCE = "EXPLICIT_PRESENCE"; + + private static final Pattern SYNTAX = Pattern.compile("^\\s*syntax\\s*=\\s*\"([^\"]+)\"\\s*;"); + private static final Pattern PACKAGE = Pattern.compile("^\\s*package\\s+([A-Za-z0-9_.]+)\\s*;"); + private static final Pattern IMPORT = + Pattern.compile("^\\s*import\\s+(?:public\\s+|weak\\s+)?\"([^\"]+)\"\\s*;"); + private static final Pattern OPTION = + Pattern.compile("^\\s*option\\s+([A-Za-z0-9_]+)\\s*=\\s*(.+?)\\s*;"); + private static final Pattern SCOPE_OPEN = + Pattern.compile("^\\s*(message|enum|service|oneof)\\s+([A-Za-z0-9_]+)\\s*\\{"); + private static final Pattern ENUM_VALUE = + Pattern.compile("^\\s*([A-Za-z0-9_]+)\\s*=\\s*(-?\\d+)\\s*(?:\\[[^\\]]*\\])?\\s*;"); + private static final Pattern MAP_FIELD = + Pattern.compile("^\\s*map\\s*<[^>]+>\\s+([A-Za-z0-9_]+)\\s*=\\s*(\\d+)"); + private static final Pattern FIELD = + Pattern.compile( + "^\\s*(optional\\s+|repeated\\s+)?([A-Za-z0-9_.]+)\\s+([A-Za-z0-9_]+)\\s*=\\s*(\\d+)"); + private static final Pattern RESERVED_NUMBERS = + Pattern.compile("^\\s*reserved\\s+([^\";]*\\d[^\";]*);"); + private static final Pattern RESERVED_NAMES = + Pattern.compile("^\\s*reserved\\s+(\"[^;]*\")\\s*;"); + private static final Pattern QUOTED_NAME = Pattern.compile("\"([A-Za-z0-9_]+)\""); + private static final Pattern NUMBER = Pattern.compile("\\d+"); + private static final Pattern LINE_COMMENT = Pattern.compile("//.*$"); + + private final GrpcProtoStyleManifest manifest; + + /** Binds a validator to the manifest it judges against. */ + public GrpcProtoContractValidator(GrpcProtoStyleManifest manifest) { + if (manifest == null) { + throw new IllegalArgumentException("a validator needs a style manifest"); + } + this.manifest = manifest; + } + + /** + * What was removed from a schema, and therefore what must stay reserved. + * + *

Keys are message names as written in the file, nested ones qualified with a dot — {@code + * PreconditionFailure.Violation}. + */ + public record SchemaHistory( + Map> removedFieldNumbers, Map> removedFieldNames) { + + /** Copies both maps. */ + public SchemaHistory { + if (removedFieldNumbers == null || removedFieldNames == null) { + throw new IllegalArgumentException("a schema history needs both removal maps"); + } + removedFieldNumbers = copyOf(removedFieldNumbers); + removedFieldNames = copyOf(removedFieldNames); + } + + private static Map> copyOf(Map> source) { + Map> copy = new LinkedHashMap<>(); + source.forEach((key, value) -> copy.put(key, Set.copyOf(value))); + return Map.copyOf(copy); + } + + /** A schema nothing has been removed from yet. */ + public static SchemaHistory empty() { + return new SchemaHistory(Map.of(), Map.of()); + } + } + + /** Validates one file against the manifest, with no removal history to check. */ + public List validate(String fileName, String source) { + return validate(fileName, source, SchemaHistory.empty()); + } + + /** + * Validates one file against the manifest and its removal history. + * + * @return every violation found, in source order; empty when the file is compliant + */ + public List validate( + String fileName, String source, SchemaHistory history) { + if (fileName == null || fileName.isBlank()) { + throw new IllegalArgumentException("a proto file needs a name"); + } + if (source == null) { + throw new IllegalArgumentException("a proto file needs source text"); + } + if (history == null) { + throw new IllegalArgumentException("a schema history must be present; use empty()"); + } + + List violations = new ArrayList<>(); + Scan scan = scan(fileName, source, violations); + + checkFileHeader(fileName, scan, violations); + checkRemovalHistory(fileName, scan, history, violations); + return List.copyOf(violations); + } + + private void checkFileHeader( + String fileName, Scan scan, List violations) { + if (!"proto3".equals(scan.syntax)) { + violations.add( + GrpcProtoRuleViolation.ofFile( + RULE_PROTO3_SYNTAX, + fileName, + "Stable schema source must declare syntax = \"proto3\"; found " + + (scan.syntax == null ? "no syntax declaration" : "'" + scan.syntax + "'"))); + } + String expectedPrefix = manifest.organization() + "."; + if (scan.protoPackage == null) { + violations.add( + GrpcProtoRuleViolation.ofFile( + RULE_PACKAGE_VERSIONED, fileName, "no package declaration")); + } else if (!scan.protoPackage.startsWith(expectedPrefix) + || !scan.protoPackage.matches(".*\\.v[1-9]\\d*$")) { + violations.add( + new GrpcProtoRuleViolation( + RULE_PACKAGE_VERSIONED, + fileName, + scan.packageLine, + "package must be '" + + manifest.organization() + + "..v'; got '" + + scan.protoPackage + + "'")); + } + if (!"true".equals(scan.options.get("java_multiple_files"))) { + violations.add( + GrpcProtoRuleViolation.ofFile( + RULE_JAVA_MULTIPLE_FILES, + fileName, + "option java_multiple_files = true is required; one outer class per file hides every " + + "generated type behind a name the schema never mentions")); + } + String javaPackage = unquote(scan.options.get("java_package")); + if (javaPackage == null || javaPackage.isBlank()) { + violations.add( + GrpcProtoRuleViolation.ofFile( + RULE_JAVA_PACKAGE_SEPARATE, fileName, "option java_package is required")); + } else { + for (String handWritten : manifest.handWrittenJavaPackages()) { + if (javaPackage.equals(handWritten) || javaPackage.startsWith(handWritten + ".")) { + violations.add( + GrpcProtoRuleViolation.ofFile( + RULE_JAVA_PACKAGE_SEPARATE, + fileName, + "generated java_package '" + + javaPackage + + "' is inside hand-written package '" + + handWritten + + "'; generated and hand-written types must not share a package")); + } + } + } + } + + private static void checkRemovalHistory( + String fileName, Scan scan, SchemaHistory history, List violations) { + history + .removedFieldNumbers() + .forEach( + (message, numbers) -> { + Set reserved = scan.reservedNumbers.getOrDefault(message, Set.of()); + Set missing = new LinkedHashSet<>(numbers); + missing.removeAll(reserved); + if (!missing.isEmpty()) { + violations.add( + GrpcProtoRuleViolation.ofFile( + RULE_RESERVED_HISTORY, + fileName, + "message '" + + message + + "' removed field numbers " + + missing.stream().sorted().toList() + + " without reserving them; the next field added would reuse a number " + + "an old client still writes")); + } + }); + history + .removedFieldNames() + .forEach( + (message, names) -> { + Set reserved = scan.reservedNames.getOrDefault(message, Set.of()); + Set missing = new LinkedHashSet<>(names); + missing.removeAll(reserved); + if (!missing.isEmpty()) { + violations.add( + GrpcProtoRuleViolation.ofFile( + RULE_RESERVED_HISTORY, + fileName, + "message '" + + message + + "' removed field names " + + missing.stream().sorted().toList() + + " without reserving them; reusing the name changes the JSON contract")); + } + }); + } + + private Scan scan(String fileName, String source, List violations) { + Scan scan = new Scan(); + Deque scopeKinds = new ArrayDeque<>(); + Deque scopeNames = new ArrayDeque<>(); + int lineNumber = 0; + + for (String rawLine : source.lines().toList()) { + lineNumber++; + String line = LINE_COMMENT.matcher(rawLine).replaceAll(""); + if (line.isBlank()) { + continue; + } + + Matcher syntax = SYNTAX.matcher(line); + if (syntax.find()) { + scan.syntax = syntax.group(1); + continue; + } + Matcher packageMatcher = PACKAGE.matcher(line); + if (packageMatcher.find()) { + scan.protoPackage = packageMatcher.group(1); + scan.packageLine = lineNumber; + continue; + } + Matcher importMatcher = IMPORT.matcher(line); + if (importMatcher.find()) { + String importPath = importMatcher.group(1); + if (!manifest.wellKnownTypeAllowed(importPath)) { + violations.add( + new GrpcProtoRuleViolation( + RULE_WELL_KNOWN_TYPE_ALLOWLIST, + fileName, + lineNumber, + "import '" + + importPath + + "' is not on the well-known type allowlist; Any and Struct erase the " + + "schema they are supposed to describe, so each use is granted by name")); + } + continue; + } + Matcher option = OPTION.matcher(line); + if (option.find() && scopeKinds.isEmpty()) { + scan.options.put(option.group(1), option.group(2)); + continue; + } + + Matcher scopeOpen = SCOPE_OPEN.matcher(line); + if (scopeOpen.find()) { + scopeKinds.push(scopeOpen.group(1)); + scopeNames.push(qualify(scopeNames, scopeOpen.group(2))); + continue; + } + if (line.strip().startsWith("}")) { + if (!scopeKinds.isEmpty()) { + scopeKinds.pop(); + scopeNames.pop(); + } + continue; + } + if (scopeKinds.isEmpty()) { + continue; + } + + String scopeKind = scopeKinds.peek(); + String scopeName = scopeNames.peek(); + if ("enum".equals(scopeKind)) { + scanEnumValue(fileName, line, lineNumber, scopeName, violations); + } else if ("message".equals(scopeKind) || "oneof".equals(scopeKind)) { + scanMessageMember(fileName, line, lineNumber, scopeName, scan, violations); + } + } + return scan; + } + + private static void scanEnumValue( + String fileName, + String line, + int lineNumber, + String enumName, + List violations) { + Matcher value = ENUM_VALUE.matcher(line); + if (!value.find()) { + return; + } + if (!"0".equals(value.group(2))) { + return; + } + String name = value.group(1); + if (!name.endsWith("_UNSPECIFIED")) { + violations.add( + new GrpcProtoRuleViolation( + RULE_ENUM_ZERO_UNSPECIFIED, + fileName, + lineNumber, + "enum '" + + enumName + + "' zero value is '" + + name + + "'; proto3 gives an unset field the zero value, so a meaningful name there is " + + "indistinguishable from a field nobody set")); + } + } + + private void scanMessageMember( + String fileName, + String line, + int lineNumber, + String messageName, + Scan scan, + List violations) { + Matcher reservedNames = RESERVED_NAMES.matcher(line); + if (reservedNames.find()) { + Matcher quoted = QUOTED_NAME.matcher(reservedNames.group(1)); + while (quoted.find()) { + scan.reservedNames + .computeIfAbsent(messageName, key -> new LinkedHashSet<>()) + .add(quoted.group(1)); + } + return; + } + Matcher reservedNumbers = RESERVED_NUMBERS.matcher(line); + if (reservedNumbers.find()) { + Matcher number = NUMBER.matcher(reservedNumbers.group(1)); + while (number.find()) { + scan.reservedNumbers + .computeIfAbsent(messageName, key -> new LinkedHashSet<>()) + .add(Integer.valueOf(number.group())); + } + return; + } + + Matcher mapField = MAP_FIELD.matcher(line); + if (mapField.find()) { + String qualified = messageName + "." + mapField.group(1); + if (!manifest.mapAllowed(qualified)) { + violations.add( + new GrpcProtoRuleViolation( + RULE_MAP_ALLOWLIST, + fileName, + lineNumber, + "map field '" + + qualified + + "' is not on the allowlist; a map is an unversioned key space that no " + + "breaking-change check can reason about")); + } + return; + } + + Matcher field = FIELD.matcher(line); + if (!field.find()) { + return; + } + String modifier = field.group(1) == null ? "" : field.group(1).strip(); + String fieldName = field.group(3); + if (manifest.presenceRequired(messageName, fieldName) && !"optional".equals(modifier)) { + violations.add( + new GrpcProtoRuleViolation( + RULE_EXPLICIT_PRESENCE, + fileName, + lineNumber, + "field '" + + messageName + + "." + + fieldName + + "' needs presence and must be declared optional; without it, absent and " + + "default-valued are the same message on the wire")); + } + } + + private static String qualify(Deque scopeNames, String name) { + String parent = scopeNames.peek(); + return parent == null ? name : parent + "." + name; + } + + private static String unquote(String value) { + if (value == null) { + return null; + } + String stripped = value.strip(); + if (stripped.length() >= 2 && stripped.startsWith("\"") && stripped.endsWith("\"")) { + return stripped.substring(1, stripped.length() - 1); + } + return stripped; + } + + /** Everything one pass over a file collected. */ + private static final class Scan { + private String syntax; + private String protoPackage; + private int packageLine; + private final Map options = new LinkedHashMap<>(); + private final Map> reservedNumbers = new LinkedHashMap<>(); + private final Map> reservedNames = new LinkedHashMap<>(); + } +} diff --git a/src/grpc/grpc-proto-contract/src/main/java/dev/caskeleton/grpc/contract/GrpcProtoRuleViolation.java b/src/grpc/grpc-proto-contract/src/main/java/dev/caskeleton/grpc/contract/GrpcProtoRuleViolation.java new file mode 100644 index 00000000..d9b7ce97 --- /dev/null +++ b/src/grpc/grpc-proto-contract/src/main/java/dev/caskeleton/grpc/contract/GrpcProtoRuleViolation.java @@ -0,0 +1,38 @@ +package dev.caskeleton.grpc.contract; + +/** + * One schema rule broken, at one place. + * + *

Returned rather than thrown, and carrying a line number, because a schema review is a list. A + * validator that throws on the first violation turns "this file breaks four rules" into four + * separate runs, and the author fixes them one at a time without ever seeing the shape of the + * problem. + */ +public record GrpcProtoRuleViolation(String rule, String file, int line, String detail) { + + /** Requires a named rule, a file and a one-based line. */ + public GrpcProtoRuleViolation { + if (rule == null || rule.isBlank()) { + throw new IllegalArgumentException("a violation names the rule it broke"); + } + if (file == null || file.isBlank()) { + throw new IllegalArgumentException("a violation names the file it is in"); + } + if (detail == null || detail.isBlank()) { + throw new IllegalArgumentException("a violation explains itself"); + } + if (line < 0) { + throw new IllegalArgumentException("line must not be negative"); + } + } + + /** A violation about a whole file rather than a line in it. */ + public static GrpcProtoRuleViolation ofFile(String rule, String file, String detail) { + return new GrpcProtoRuleViolation(rule, file, 0, detail); + } + + /** {@code file:line rule — detail}, the form a build log is read in. */ + public String describe() { + return file + (line > 0 ? ":" + line : "") + " " + rule + " — " + detail; + } +} diff --git a/src/grpc/grpc-proto-contract/src/main/java/dev/caskeleton/grpc/contract/GrpcProtoStyleManifest.java b/src/grpc/grpc-proto-contract/src/main/java/dev/caskeleton/grpc/contract/GrpcProtoStyleManifest.java new file mode 100644 index 00000000..7e4040dc --- /dev/null +++ b/src/grpc/grpc-proto-contract/src/main/java/dev/caskeleton/grpc/contract/GrpcProtoStyleManifest.java @@ -0,0 +1,120 @@ +package dev.caskeleton.grpc.contract; + +import java.util.Map; +import java.util.Set; + +/** + * The schema rules this repository has decided on, as data rather than as prose in a review + * checklist. + * + *

Three of the five fields are allowlists, and that shape is the decision: {@code Any}, {@code + * Struct} and {@code map} are not banned, they are things you have to ask for by name. A ban gets + * worked around; an allowlist entry gets read by the next person to open the manifest and carries + * the field it was granted for. + */ +public record GrpcProtoStyleManifest( + String organization, + Set handWrittenJavaPackages, + Set wellKnownTypeAllowlist, + Set mapFieldAllowlist, + Map> presenceRequiredFields) { + + /** The well-known types every schema may use without asking. */ + private static final Set ALWAYS_ALLOWED_WELL_KNOWN_TYPES = + Set.of( + "google/protobuf/timestamp.proto", + "google/protobuf/duration.proto", + "google/protobuf/field_mask.proto", + "google/protobuf/empty.proto", + "google/protobuf/wrappers.proto"); + + /** Copies every collection so a manifest cannot widen after it has been reviewed. */ + public GrpcProtoStyleManifest { + if (organization == null || organization.isBlank()) { + throw new IllegalArgumentException("a style manifest names the owning organization"); + } + if (handWrittenJavaPackages == null + || wellKnownTypeAllowlist == null + || mapFieldAllowlist == null + || presenceRequiredFields == null) { + throw new IllegalArgumentException("every manifest allowlist must be present"); + } + handWrittenJavaPackages = Set.copyOf(handWrittenJavaPackages); + wellKnownTypeAllowlist = Set.copyOf(wellKnownTypeAllowlist); + mapFieldAllowlist = Set.copyOf(mapFieldAllowlist); + presenceRequiredFields = + presenceRequiredFields.entrySet().stream() + .collect( + java.util.stream.Collectors.toUnmodifiableMap( + Map.Entry::getKey, entry -> Set.copyOf(entry.getValue()))); + } + + /** + * This repository's manifest. + * + *

The hand-written package is {@code dev.caskeleton.grpc}: generated code must land somewhere + * else, because a generated class and a hand-written one in the same package are + * indistinguishable to a reader and the generated one gets edited exactly once before somebody + * regenerates. + */ + public static GrpcProtoStyleManifest caSkeleton() { + return new GrpcProtoStyleManifest( + "hyeonworks", + Set.of("dev.caskeleton"), + ALWAYS_ALLOWED_WELL_KNOWN_TYPES, + Set.of(), + Map.of()); + } + + /** Whether {@code importPath} may be imported. */ + public boolean wellKnownTypeAllowed(String importPath) { + return !importPath.startsWith("google/protobuf/") + || wellKnownTypeAllowlist.contains(importPath); + } + + /** Whether a {@code map} field is allowed at {@code message.field}. */ + public boolean mapAllowed(String qualifiedField) { + return mapFieldAllowlist.contains(qualifiedField); + } + + /** Whether {@code field} of {@code message} must be declared {@code optional}. */ + public boolean presenceRequired(String message, String field) { + return presenceRequiredFields.getOrDefault(message, Set.of()).contains(field); + } + + /** A copy of this manifest that also allows the given well-known type imports. */ + public GrpcProtoStyleManifest allowingWellKnownTypes(Set additionalImports) { + java.util.Set widened = new java.util.LinkedHashSet<>(wellKnownTypeAllowlist); + widened.addAll(additionalImports); + return new GrpcProtoStyleManifest( + organization, handWrittenJavaPackages, widened, mapFieldAllowlist, presenceRequiredFields); + } + + /** A copy of this manifest that also allows the given {@code message.field} map fields. */ + public GrpcProtoStyleManifest allowingMapFields(Set qualifiedFields) { + java.util.Set widened = new java.util.LinkedHashSet<>(mapFieldAllowlist); + widened.addAll(qualifiedFields); + return new GrpcProtoStyleManifest( + organization, + handWrittenJavaPackages, + wellKnownTypeAllowlist, + widened, + presenceRequiredFields); + } + + /** A copy of this manifest that also requires explicit presence on the given fields. */ + public GrpcProtoStyleManifest requiringPresence(String message, Set fields) { + java.util.Map> widened = + new java.util.LinkedHashMap<>(presenceRequiredFields); + widened.merge( + message, + Set.copyOf(fields), + (existing, added) -> { + java.util.Set merged = new java.util.LinkedHashSet<>(existing); + merged.addAll(added); + return merged; + }); + return new GrpcProtoStyleManifest( + organization, handWrittenJavaPackages, wellKnownTypeAllowlist, mapFieldAllowlist, widened); + } +} diff --git a/src/grpc/grpc-proto-contract/src/main/resources/proto/buf.gen.yaml b/src/grpc/grpc-proto-contract/src/main/resources/proto/buf.gen.yaml new file mode 100644 index 00000000..a357d276 --- /dev/null +++ b/src/grpc/grpc-proto-contract/src/main/resources/proto/buf.gen.yaml @@ -0,0 +1,23 @@ +# What a Buf-driven generation would emit, recorded even though this build does not run it. +# +# No protoc runs here (ADR-GRPC-002), so this file is a specification rather than a step. It exists +# because the three decisions it encodes are the ones GrpcCodegenManifest enforces from Java, and a +# reader who wants to know what "the single codegen owner produces" means should find one answer +# rather than two: +# +# - generated Java lands under build/, never in a source tree; +# - the generated package is disjoint from the hand-written dev.caskeleton root; +# - plugin versions come from the managed platform, so no version literal appears below. +version: v2 +managed: + enabled: true + override: + - file_option: java_multiple_files + value: true + - file_option: java_package_suffix + value: generated +plugins: + - remote: buf.build/protocolbuffers/java + out: build/generated/source/proto/main/java + - remote: buf.build/grpc/java + out: build/generated/source/proto/main/grpc diff --git a/src/grpc/grpc-proto-contract/src/main/resources/proto/buf.lock b/src/grpc/grpc-proto-contract/src/main/resources/proto/buf.lock new file mode 100644 index 00000000..dc72df55 --- /dev/null +++ b/src/grpc/grpc-proto-contract/src/main/resources/proto/buf.lock @@ -0,0 +1,11 @@ +# Buf dependency lock. +# +# Empty by design: the Stable schema imports nothing outside itself. The well-known types this +# platform allows (timestamp, duration, field_mask, empty, wrappers) ship with protoc rather than +# coming from a Buf module, and GrpcProtoStyleManifest refuses any other google.protobuf import +# without an explicit allowlist entry. +# +# The file is committed rather than omitted so that adding a first dependency is a visible diff in a +# file that already exists, instead of a new file nobody reviews. +version: v2 +deps: [] diff --git a/src/grpc/grpc-proto-contract/src/main/resources/proto/buf.yaml b/src/grpc/grpc-proto-contract/src/main/resources/proto/buf.yaml new file mode 100644 index 00000000..22b2a1d2 --- /dev/null +++ b/src/grpc/grpc-proto-contract/src/main/resources/proto/buf.yaml @@ -0,0 +1,18 @@ +# Buf module configuration for the Stable schema. +# +# The rules named here are also implemented in GrpcProtoContractValidator, which is what actually +# fails this repository's build: the Buf CLI is not part of this toolchain, and a gate that silently +# passes when a binary is missing is worse than one that computes the same judgement from the +# committed schema. This file stays accurate so that running `buf lint` / `buf breaking` in an +# environment that has the CLI reaches the same verdict. +version: v2 +modules: + - path: . +lint: + use: + - STANDARD +breaking: + # FILE, not WIRE or WIRE_JSON. Wire compatibility alone would let a field rename or a Java package + # move through as "compatible" while every generated consumer stops compiling. + use: + - FILE diff --git a/src/grpc/grpc-proto-contract/src/main/resources/proto/hyeonworks/grpc/common/v1/error.proto b/src/grpc/grpc-proto-contract/src/main/resources/proto/hyeonworks/grpc/common/v1/error.proto new file mode 100644 index 00000000..af32d4d9 --- /dev/null +++ b/src/grpc/grpc-proto-contract/src/main/resources/proto/hyeonworks/grpc/common/v1/error.proto @@ -0,0 +1,66 @@ +syntax = "proto3"; + +package hyeonworks.grpc.common.v1; + +option java_multiple_files = true; +option java_package = "hyeonworks.grpc.common.v1.generated"; +option java_outer_classname = "ErrorProto"; + +// The platform's allowlisted rich error details. +// +// Deliberately this repository's own messages rather than google.rpc.*: the Stable contract is that +// a client branches on a code, a reason and a typed detail — never on a message string — and owning +// the detail types is what keeps that surface reviewable. The shapes mirror google.rpc so a future +// move onto the common protos is a rename rather than a redesign. + +// Why a request was refused before it reached a use case. +message FieldViolation { + // Fully qualified field path, e.g. "document.title". + string field = 1; + // Stable machine-readable reason, e.g. "LENGTH_EXCEEDED". + string reason = 2; + // Human-readable description. Never echoes the rejected value. + optional string description = 3; +} + +// The transport validator's verdict. +message BadRequest { + repeated FieldViolation field_violations = 1; +} + +// Whether and when the caller may try again. The only channel for a retry hint. +message RetryInfo { + // Seconds to wait before the next attempt. Zero means "do not retry". + int64 retry_delay_seconds = 1; + // Attempts already made, 1-based. + int32 attempt = 2; +} + +// A stable classification a client may branch on. +message ErrorInfo { + // Stable reason constant, e.g. "IDEMPOTENCY_KEY_REQUIRED". + string reason = 1; + // The service that produced the failure. + string domain = 2; + // Opaque execution id an operator can correlate. Never a stack trace or a driver message. + optional string execution_id = 3; +} + +// The state that made the operation impossible. +message PreconditionFailure { + message Violation { + string type = 1; + string subject = 2; + optional string description = 3; + } + repeated Violation violations = 1; +} + +// What the caller may conclude about a state-changing call. +enum CompletionOutcome { + COMPLETION_OUTCOME_UNSPECIFIED = 0; + COMPLETION_OUTCOME_COMPLETED = 1; + COMPLETION_OUTCOME_REJECTED = 2; + COMPLETION_OUTCOME_COMPLETION_UNKNOWN = 3; + COMPLETION_OUTCOME_PARTIAL_STREAM = 4; +} diff --git a/src/grpc/grpc-proto-contract/src/main/resources/proto/hyeonworks/grpc/common/v1/stream.proto b/src/grpc/grpc-proto-contract/src/main/resources/proto/hyeonworks/grpc/common/v1/stream.proto new file mode 100644 index 00000000..67bf8a3d --- /dev/null +++ b/src/grpc/grpc-proto-contract/src/main/resources/proto/hyeonworks/grpc/common/v1/stream.proto @@ -0,0 +1,57 @@ +syntax = "proto3"; + +package hyeonworks.grpc.common.v1; + +option java_multiple_files = true; +option java_package = "hyeonworks.grpc.common.v1.generated"; +option java_outer_classname = "StreamProto"; + +// The Stable server-streaming envelope. +// +// Every Stable server stream carries these fields around its payload, because resume, gap detection +// and drain all need a position and a generation, and a stream that ships payloads alone can offer +// none of them. + +// Why a stream ended. +enum StreamTerminationReason { + STREAM_TERMINATION_REASON_UNSPECIFIED = 0; + STREAM_TERMINATION_REASON_COMPLETED = 1; + STREAM_TERMINATION_REASON_CLIENT_CANCELLED = 2; + STREAM_TERMINATION_REASON_IDLE_TIMEOUT = 3; + STREAM_TERMINATION_REASON_MAX_DURATION = 4; + STREAM_TERMINATION_REASON_SERVER_DRAIN = 5; + STREAM_TERMINATION_REASON_SLOW_CONSUMER = 6; + STREAM_TERMINATION_REASON_CREDENTIAL_EXPIRED = 7; + STREAM_TERMINATION_REASON_FULL_RESYNC_REQUIRED = 8; +} + +// What a stream message is. +enum StreamMessageKind { + STREAM_MESSAGE_KIND_UNSPECIFIED = 0; + STREAM_MESSAGE_KIND_SNAPSHOT = 1; + STREAM_MESSAGE_KIND_SNAPSHOT_COMPLETE = 2; + STREAM_MESSAGE_KIND_LIVE = 3; + STREAM_MESSAGE_KIND_HEARTBEAT = 4; + STREAM_MESSAGE_KIND_TERMINATION = 5; +} + +// The envelope every Stable server-stream message carries. +message StreamEnvelope { + // Opaque stream identity. Never used as a metric label. + string stream_id = 1; + // Monotonic within one stream generation. Restarts only on a new generation. + uint64 sequence = 2; + // The snapshot this stream is consistent with. Distinct from the resume cursor. + optional string snapshot_version = 3; + // Opaque, signed continuation handle. Absent when the stream is not resumable. + optional string resume_token = 4; + StreamMessageKind kind = 5; + // Set only on a terminal message. + optional StreamTerminationReason termination_reason = 6; +} + +// A liveness signal. Explicitly not an application acknowledgement and not an ordering guarantee. +message StreamHeartbeat { + StreamEnvelope envelope = 1; + int64 server_time_unix_millis = 2; +} diff --git a/src/grpc/grpc-proto-contract/src/test/java/dev/caskeleton/grpc/contract/GrpcProtoContractValidatorTest.java b/src/grpc/grpc-proto-contract/src/test/java/dev/caskeleton/grpc/contract/GrpcProtoContractValidatorTest.java new file mode 100644 index 00000000..6d190909 --- /dev/null +++ b/src/grpc/grpc-proto-contract/src/test/java/dev/caskeleton/grpc/contract/GrpcProtoContractValidatorTest.java @@ -0,0 +1,324 @@ +package dev.caskeleton.grpc.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcProtoContractValidatorTest { + + private final GrpcProtoContractValidator validator = + new GrpcProtoContractValidator(GrpcProtoStyleManifest.caSkeleton()); + + private static String committedSchema(String resourcePath) { + try (InputStream stream = + GrpcProtoContractValidatorTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + if (stream == null) { + throw new IllegalStateException("missing committed schema resource: " + resourcePath); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Test + @DisplayName("the committed Stable schema passes every style rule") + void committedSchemaIsCompliant() { + List files = + List.of( + "proto/hyeonworks/grpc/common/v1/error.proto", + "proto/hyeonworks/grpc/common/v1/stream.proto"); + + for (String file : files) { + List violations = validator.validate(file, committedSchema(file)); + assertThat(violations) + .describedAs( + "violations in %s: %s", + file, violations.stream().map(GrpcProtoRuleViolation::describe).toList()) + .isEmpty(); + } + } + + @Test + @DisplayName("the committed Buf configuration states the same rules the validator enforces") + void theBufConfigurationAgreesWithTheValidator() { + String bufYaml = committedSchema("proto/buf.yaml"); + String bufGen = committedSchema("proto/buf.gen.yaml"); + String bufLock = committedSchema("proto/buf.lock"); + + // FILE, not WIRE: the category the Stable gate is fixed at. + assertThat(bufYaml).contains("use:").contains("- FILE").contains("- STANDARD"); + // Generation lands under build/ and into a package the hand-written root does not occupy. + assertThat(bufGen) + .contains("build/generated/source/proto/main/java") + .contains("build/generated/source/proto/main/grpc") + .contains("java_package_suffix") + // Never into a source tree: generated code that lands in src/ gets committed and then + // edited. The prose above the plugins block names dev.caskeleton as the package to stay out + // of, so the check is on the emitted paths rather than on the word. + .doesNotContain("out: src/"); + // No version literal: the managed platform owns plugin versions. + assertThat(bufGen).doesNotContain("version: v1"); + assertThat(bufLock).contains("deps: []"); + } + + @Test + @DisplayName("proto2 source is refused") + void proto2SourceIsRefused() { + String source = + """ + syntax = "proto2"; + package hyeonworks.document.v1; + option java_multiple_files = true; + option java_package = "hyeonworks.document.v1.generated"; + """; + + assertThat(validator.validate("legacy.proto", source)) + .extracting(GrpcProtoRuleViolation::rule) + .contains(GrpcProtoContractValidator.RULE_PROTO3_SYNTAX); + } + + @Test + @DisplayName("an unversioned package is refused") + void unversionedPackageIsRefused() { + String source = + """ + syntax = "proto3"; + package hyeonworks.document; + option java_multiple_files = true; + option java_package = "hyeonworks.document.generated"; + """; + + assertThat(validator.validate("document.proto", source)) + .extracting(GrpcProtoRuleViolation::rule) + .contains(GrpcProtoContractValidator.RULE_PACKAGE_VERSIONED); + } + + @Test + @DisplayName("a generated java_package inside a hand-written package is refused") + void generatedPackageMayNotCollideWithHandWrittenCode() { + String source = + """ + syntax = "proto3"; + package hyeonworks.document.v1; + option java_multiple_files = true; + option java_package = "dev.caskeleton.grpc.document"; + """; + + assertThat(validator.validate("document.proto", source)) + .extracting(GrpcProtoRuleViolation::rule) + .contains(GrpcProtoContractValidator.RULE_JAVA_PACKAGE_SEPARATE); + } + + @Test + @DisplayName("a missing java_multiple_files option is refused") + void javaMultipleFilesIsRequired() { + String source = + """ + syntax = "proto3"; + package hyeonworks.document.v1; + option java_package = "hyeonworks.document.v1.generated"; + """; + + assertThat(validator.validate("document.proto", source)) + .extracting(GrpcProtoRuleViolation::rule) + .contains(GrpcProtoContractValidator.RULE_JAVA_MULTIPLE_FILES); + } + + @Test + @DisplayName("an enum zero value without the _UNSPECIFIED suffix is refused, with its line") + void enumZeroValueNeedsTheUnspecifiedSuffix() { + String source = + """ + syntax = "proto3"; + package hyeonworks.document.v1; + option java_multiple_files = true; + option java_package = "hyeonworks.document.v1.generated"; + + enum DocumentState { + DRAFT = 0; + PUBLISHED = 1; + } + """; + + List violations = validator.validate("document.proto", source); + + assertThat(violations) + .singleElement() + .satisfies( + violation -> { + assertThat(violation.rule()) + .isEqualTo(GrpcProtoContractValidator.RULE_ENUM_ZERO_UNSPECIFIED); + assertThat(violation.line()).isEqualTo(7); + assertThat(violation.detail()).contains("DocumentState").contains("DRAFT"); + }); + } + + @Test + @DisplayName("a removed field number that is not reserved fails, and reserving it passes") + void removedFieldNumbersMustStayReserved() { + String withoutReserved = + """ + syntax = "proto3"; + package hyeonworks.document.v1; + option java_multiple_files = true; + option java_package = "hyeonworks.document.v1.generated"; + + message Document { + string id = 1; + string title = 4; + } + """; + GrpcProtoContractValidator.SchemaHistory history = + new GrpcProtoContractValidator.SchemaHistory( + Map.of("Document", Set.of(2, 3)), Map.of("Document", Set.of("legacy_body"))); + + assertThat(validator.validate("document.proto", withoutReserved, history)) + .extracting(GrpcProtoRuleViolation::rule) + .containsOnly(GrpcProtoContractValidator.RULE_RESERVED_HISTORY); + + String withReserved = + """ + syntax = "proto3"; + package hyeonworks.document.v1; + option java_multiple_files = true; + option java_package = "hyeonworks.document.v1.generated"; + + message Document { + reserved 2, 3; + reserved "legacy_body"; + string id = 1; + string title = 4; + } + """; + + assertThat(validator.validate("document.proto", withReserved, history)).isEmpty(); + } + + @Test + @DisplayName("Any and Struct need an explicit allowlist entry; Timestamp does not") + void anyAndStructAreAllowlistedByName() { + String source = + """ + syntax = "proto3"; + package hyeonworks.document.v1; + import "google/protobuf/timestamp.proto"; + import "google/protobuf/any.proto"; + option java_multiple_files = true; + option java_package = "hyeonworks.document.v1.generated"; + """; + + assertThat(validator.validate("document.proto", source)) + .singleElement() + .satisfies( + violation -> { + assertThat(violation.rule()) + .isEqualTo(GrpcProtoContractValidator.RULE_WELL_KNOWN_TYPE_ALLOWLIST); + assertThat(violation.detail()).contains("google/protobuf/any.proto"); + }); + + GrpcProtoContractValidator widened = + new GrpcProtoContractValidator( + GrpcProtoStyleManifest.caSkeleton() + .allowingWellKnownTypes(Set.of("google/protobuf/any.proto"))); + assertThat(widened.validate("document.proto", source)).isEmpty(); + } + + @Test + @DisplayName("a map field needs an allowlist entry naming the message and field") + void mapFieldsAreAllowlistedByName() { + String source = + """ + syntax = "proto3"; + package hyeonworks.document.v1; + option java_multiple_files = true; + option java_package = "hyeonworks.document.v1.generated"; + + message Document { + map labels = 1; + } + """; + + assertThat(validator.validate("document.proto", source)) + .extracting(GrpcProtoRuleViolation::rule) + .containsOnly(GrpcProtoContractValidator.RULE_MAP_ALLOWLIST); + + GrpcProtoContractValidator widened = + new GrpcProtoContractValidator( + GrpcProtoStyleManifest.caSkeleton().allowingMapFields(Set.of("Document.labels"))); + assertThat(widened.validate("document.proto", source)).isEmpty(); + } + + @Test + @DisplayName("a presence-required field must be declared optional") + void presenceRequiredFieldsMustBeOptional() { + String source = + """ + syntax = "proto3"; + package hyeonworks.document.v1; + option java_multiple_files = true; + option java_package = "hyeonworks.document.v1.generated"; + + message Document { + string id = 1; + string title = 2; + } + """; + GrpcProtoContractValidator strict = + new GrpcProtoContractValidator( + GrpcProtoStyleManifest.caSkeleton().requiringPresence("Document", Set.of("title"))); + + assertThat(strict.validate("document.proto", source)) + .singleElement() + .satisfies( + violation -> { + assertThat(violation.rule()) + .isEqualTo(GrpcProtoContractValidator.RULE_EXPLICIT_PRESENCE); + assertThat(violation.detail()).contains("Document.title"); + }); + + String withOptional = source.replace(" string title = 2;", " optional string title = 2;"); + assertThat(strict.validate("document.proto", withOptional)).isEmpty(); + } + + @Test + @DisplayName("nested messages are qualified, so a rule names the type it means") + void nestedMessagesAreQualified() { + String source = + """ + syntax = "proto3"; + package hyeonworks.document.v1; + option java_multiple_files = true; + option java_package = "hyeonworks.document.v1.generated"; + + message Outer { + message Inner { + map labels = 1; + } + } + """; + + assertThat(validator.validate("document.proto", source)) + .singleElement() + .satisfies(violation -> assertThat(violation.detail()).contains("Outer.Inner.labels")); + } + + @Test + @DisplayName("a violation renders as file:line rule — detail") + void violationRendersForABuildLog() { + GrpcProtoRuleViolation violation = + new GrpcProtoRuleViolation("RULE_X", "document.proto", 12, "something is wrong"); + + assertThat(violation.describe()).isEqualTo("document.proto:12 RULE_X — something is wrong"); + assertThat(GrpcProtoRuleViolation.ofFile("RULE_Y", "document.proto", "whole file").describe()) + .isEqualTo("document.proto RULE_Y — whole file"); + } +} diff --git a/src/grpc/grpc-server/build.gradle b/src/grpc/grpc-server/build.gradle new file mode 100644 index 00000000..965ad2b8 --- /dev/null +++ b/src/grpc/grpc-server/build.gradle @@ -0,0 +1,20 @@ +apply plugin: 'java-library' + +// Server boundary: the ArchUnit-shaped application boundary rules, the typed service adapter SPI, +// the interceptor order contract, and the Netty server/executor/admission profiles. +// +// The Netty profiles are configuration models, not Netty wiring — no netty dependency here. Real +// Netty lives in `grpc-testkit`'s certification lane, which is where transport evidence is produced. +dependencyManagement { + imports { + mavenBom "io.grpc:grpc-bom:${grpcVersion}" + } +} + +dependencies { + api project(':grpc:grpc-core-api') + api project(':grpc:grpc-policy') + + api "io.grpc:grpc-api:${grpcVersion}" + api "io.grpc:grpc-stub:${grpcVersion}" +} diff --git a/src/grpc/grpc-server/gradle.lockfile b/src/grpc/grpc-server/gradle.lockfile new file mode 100644 index 00000000..9369e831 --- /dev/null +++ b/src/grpc/grpc-server/gradle.lockfile @@ -0,0 +1,91 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty= diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/architecture/GrpcApplicationBoundaryRules.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/architecture/GrpcApplicationBoundaryRules.java new file mode 100644 index 00000000..0d62e929 --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/architecture/GrpcApplicationBoundaryRules.java @@ -0,0 +1,92 @@ +package dev.caskeleton.grpc.architecture; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * What a gRPC service adapter may not reach. + * + *

The list is package prefixes rather than a prose rule, so it can be applied by an architecture + * test, by a source scan and by a review checklist without three people deciding what "must not use + * a repository" covers. + * + *

The rule these encode is one of this repository's hard stops: a transport adapter that calls a + * repository has moved the use case into the transport, and the next caller of that use case — a + * scheduled job, a message consumer — either duplicates it or reaches through the controller. + */ +public final class GrpcApplicationBoundaryRules { + + /** Package prefixes an adapter may not reference. */ + private static final Set FORBIDDEN_PREFIXES = + Set.of( + "jakarta.persistence", + "org.hibernate", + "org.springframework.data", + "org.springframework.jdbc", + "org.springframework.orm", + "org.springframework.web.client", + "org.springframework.web.reactive.function.client", + "java.net.http", + "org.apache.kafka", + "org.springframework.kafka", + "org.springframework.amqp", + "software.amazon.awssdk", + "dev.caskeleton.adapter.outbound"); + + /** Exact types an adapter may not reference, where the package alone is too broad. */ + private static final Set FORBIDDEN_TYPES = + Set.of("javax.sql.DataSource", "java.sql.Connection", "java.sql.DriverManager"); + + private GrpcApplicationBoundaryRules() {} + + /** The forbidden package prefixes, for an architecture test to consume. */ + public static Set forbiddenPackagePrefixes() { + return FORBIDDEN_PREFIXES; + } + + /** The forbidden exact types. */ + public static Set forbiddenTypes() { + return FORBIDDEN_TYPES; + } + + /** + * Every forbidden reference in {@code referencedTypes}. + * + * @param adapterName the adapter under test, for the message + * @return an empty list when the adapter stays inside the boundary + */ + public static List violations(String adapterName, Set referencedTypes) { + if (adapterName == null || adapterName.isBlank()) { + throw new IllegalArgumentException("a boundary check names the adapter it is about"); + } + if (referencedTypes == null) { + throw new IllegalArgumentException("the referenced type set must not be null"); + } + List violations = new ArrayList<>(); + referencedTypes.stream() + .sorted() + .forEach( + referenced -> { + if (FORBIDDEN_TYPES.contains(referenced)) { + violations.add( + adapterName + " references " + referenced + ", which belongs behind a port"); + return; + } + FORBIDDEN_PREFIXES.stream() + .filter(prefix -> referenced.startsWith(prefix + ".")) + .findFirst() + .ifPresent( + prefix -> + violations.add( + adapterName + + " references " + + referenced + + " from forbidden package '" + + prefix + + "'; a transport adapter reaches persistence, messaging and " + + "HTTP only through an application port")); + }); + return List.copyOf(violations); + } +} diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/architecture/GrpcRawApiImportRule.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/architecture/GrpcRawApiImportRule.java new file mode 100644 index 00000000..5a9a20ac --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/architecture/GrpcRawApiImportRule.java @@ -0,0 +1,108 @@ +package dev.caskeleton.grpc.architecture; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Keeps raw gRPC construction APIs out of ordinary application code. + * + *

The types below are the ones that build a channel, a server or a call by hand. Every one of + * them bypasses a Stable guarantee: a hand-built {@code ManagedChannel} has no named profile, no + * deadline policy and no credential rotation; a hand-built {@code ClientCall} skips the interceptor + * chain entirely. They are legitimate inside the platform and inside generated code, which is why + * this rule takes an allowlist of packages rather than banning them outright. + */ +public final class GrpcRawApiImportRule { + + /** The construction APIs ordinary application code may not import. */ + private static final Set RAW_API_TYPES = + Set.of( + "io.grpc.ManagedChannelBuilder", + "io.grpc.ManagedChannel", + "io.grpc.ServerBuilder", + "io.grpc.Server", + "io.grpc.ClientCall", + "io.grpc.ClientCalls", + "io.grpc.MethodDescriptor", + "io.grpc.CallCredentials", + "io.grpc.netty.NettyChannelBuilder", + "io.grpc.netty.NettyServerBuilder", + "io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder", + "io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder", + "io.grpc.inprocess.InProcessChannelBuilder", + "io.grpc.inprocess.InProcessServerBuilder"); + + private static final Pattern IMPORT = + Pattern.compile("^\\s*import\\s+(?:static\\s+)?([\\w.]+)\\s*;", Pattern.MULTILINE); + private static final Pattern PACKAGE = + Pattern.compile("^\\s*package\\s+([\\w.]+)\\s*;", Pattern.MULTILINE); + + private final Set allowedPackagePrefixes; + + /** + * @param allowedPackagePrefixes packages where raw construction is the job — the platform's own + * leaves and the generated code root. Everything else reaches gRPC through a typed stub or a + * service adapter. + */ + public GrpcRawApiImportRule(Set allowedPackagePrefixes) { + if (allowedPackagePrefixes == null || allowedPackagePrefixes.isEmpty()) { + throw new IllegalArgumentException( + "a raw-API rule needs at least one allowed package; the platform itself has to build " + + "channels somewhere"); + } + this.allowedPackagePrefixes = Set.copyOf(allowedPackagePrefixes); + } + + /** This repository's rule: the platform leaves and the generated package may construct. */ + public static GrpcRawApiImportRule caSkeleton() { + return new GrpcRawApiImportRule(Set.of("dev.caskeleton.grpc", "hyeonworks.grpc")); + } + + /** The raw construction types this rule governs. */ + public static Set rawApiTypes() { + return RAW_API_TYPES; + } + + /** + * Every forbidden import in one Java source file. + * + * @return an empty list when the file is compliant, or when it is inside an allowed package + */ + public List violations(String fileName, String source) { + if (fileName == null || fileName.isBlank()) { + throw new IllegalArgumentException("a source file needs a name"); + } + if (source == null) { + throw new IllegalArgumentException("a source file needs its text"); + } + Matcher packageMatcher = PACKAGE.matcher(source); + String declaredPackage = packageMatcher.find() ? packageMatcher.group(1) : ""; + if (allowed(declaredPackage)) { + return List.of(); + } + + List violations = new ArrayList<>(); + Matcher imports = IMPORT.matcher(source); + while (imports.find()) { + String imported = imports.group(1); + if (RAW_API_TYPES.contains(imported)) { + violations.add( + fileName + + " imports " + + imported + + "; a hand-built channel, server or call has no named profile, no deadline policy " + + "and no interceptor chain. Use a typed stub or a service adapter."); + } + } + return List.copyOf(violations); + } + + /** Whether {@code packageName} may construct raw gRPC objects. */ + public boolean allowed(String packageName) { + return allowedPackagePrefixes.stream() + .anyMatch(prefix -> packageName.equals(prefix) || packageName.startsWith(prefix + ".")); + } +} diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/architecture/GrpcServiceAdapterMarker.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/architecture/GrpcServiceAdapterMarker.java new file mode 100644 index 00000000..73c11394 --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/architecture/GrpcServiceAdapterMarker.java @@ -0,0 +1,25 @@ +package dev.caskeleton.grpc.architecture; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a class as a gRPC service adapter, which is what the boundary rules are stated about. + * + *

An annotation rather than a naming convention, because a rule keyed on a suffix is a rule that + * stops applying the moment somebody names a class {@code DocumentGrpcFacade}. The marker is what a + * rule set can enumerate exactly. + * + *

Runtime retention so an architecture test can find the classes without a source scan. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface GrpcServiceAdapterMarker { + + /** The full method names this adapter serves, for cross-checking against the policy catalog. */ + String[] methods() default {}; +} diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcAdmissionController.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcAdmissionController.java new file mode 100644 index 00000000..c25f67a2 --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcAdmissionController.java @@ -0,0 +1,103 @@ +package dev.caskeleton.grpc.server; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Refuses work the server cannot do, before it spends anything on it. + * + *

Rejecting with {@code RESOURCE_EXHAUSTED} is a better outcome than queueing for two reasons + * that both matter under load: the client learns immediately and can shed or retry elsewhere, and + * the server stops accumulating work whose callers have already given up. A server that queues + * instead spends its capacity finishing requests nobody is reading. + */ +public final class GrpcAdmissionController { + + private final int maxConcurrentCalls; + private final int maxQueuedCalls; + private final AtomicInteger inFlight = new AtomicInteger(); + private final AtomicInteger queued = new AtomicInteger(); + private final AtomicInteger rejected = new AtomicInteger(); + + /** Bounds concurrency and queueing separately. */ + public GrpcAdmissionController(int maxConcurrentCalls, int maxQueuedCalls) { + if (maxConcurrentCalls < 1 || maxQueuedCalls < 0) { + throw new IllegalArgumentException("admission bounds must be positive"); + } + this.maxConcurrentCalls = maxConcurrentCalls; + this.maxQueuedCalls = maxQueuedCalls; + } + + /** An admission controller sized from an executor profile. */ + public static GrpcAdmissionController forExecutor(GrpcExecutorProfile profile) { + if (profile == null) { + throw new IllegalArgumentException("an admission controller needs an executor profile"); + } + return new GrpcAdmissionController( + profile.virtualThreads() ? profile.queueCapacity() : profile.maxPoolSize(), + profile.queueCapacity()); + } + + /** What the controller decided, and why. */ + public record Decision(boolean admitted, int inFlight, int queued, String reason) { + /** Requires a reason. */ + public Decision { + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("an admission decision explains itself"); + } + } + } + + /** Admits a call, queues it, or refuses it. */ + public Decision tryAdmit() { + int running = inFlight.get(); + if (running < maxConcurrentCalls) { + inFlight.incrementAndGet(); + return new Decision(true, inFlight.get(), queued.get(), "within the concurrency bound"); + } + int waiting = queued.get(); + if (waiting < maxQueuedCalls) { + queued.incrementAndGet(); + return new Decision(true, running, queued.get(), "queued within the admission bound"); + } + rejected.incrementAndGet(); + return new Decision( + false, + running, + waiting, + "at capacity: " + + running + + " in flight and " + + waiting + + " queued. Refusing is better than accepting work whose caller will have given up."); + } + + /** Moves a queued call into execution. */ + public void promoteFromQueue() { + if (queued.get() > 0) { + queued.decrementAndGet(); + inFlight.incrementAndGet(); + } + } + + /** Releases a finished call. */ + public void release() { + if (inFlight.get() > 0) { + inFlight.decrementAndGet(); + } + } + + /** How many calls are executing. */ + public int inFlight() { + return inFlight.get(); + } + + /** How many calls are waiting. */ + public int queued() { + return queued.get(); + } + + /** How many calls have been refused. */ + public int rejected() { + return rejected.get(); + } +} diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcApplicationInvocation.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcApplicationInvocation.java new file mode 100644 index 00000000..68e78b5e --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcApplicationInvocation.java @@ -0,0 +1,28 @@ +package dev.caskeleton.grpc.server; + +import dev.caskeleton.grpc.context.GrpcRequestContext; + +/** + * The single call an adapter makes into the application. + * + *

Its shape is the boundary. The adapter hands over an application command and a {@link + * GrpcRequestContext}, and receives an application result; there is no parameter through which a + * transport type could travel, and no return through which one could come back. That is what keeps + * the third hard stop — no inbound DTO in the application layer — checkable rather than + * aspirational. + * + * @param the application command type + * @param the application result type + */ +@FunctionalInterface +public interface GrpcApplicationInvocation { + + /** + * Runs the use case. + * + * @param command the application-layer command, already mapped out of the request message + * @param context the caller, the deadline and the cancellation token — never credentials or raw + * metadata + */ + R invoke(C command, GrpcRequestContext context); +} diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcExecutorProfile.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcExecutorProfile.java new file mode 100644 index 00000000..e60ba20c --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcExecutorProfile.java @@ -0,0 +1,66 @@ +package dev.caskeleton.grpc.server; + +import java.time.Duration; + +/** + * The executor a blocking use case runs on, and the two things that make it safe. + * + *

Bounded pool, bounded queue. An unbounded queue does not prevent overload, it defers it: the + * server accepts every call, the queue grows, latency climbs past every client's deadline, and the + * work being done is work nobody is waiting for any more. A bounded queue turns that into a {@code + * RESOURCE_EXHAUSTED} the client can act on. + * + *

{@code directExecutor} is refused outright. It runs application code on the transport thread, + * where one blocking call stalls every other call multiplexed on the same connection. + */ +public record GrpcExecutorProfile( + int corePoolSize, + int maxPoolSize, + int queueCapacity, + Duration keepAlive, + boolean virtualThreads) { + + /** Refuses an unbounded or transport-thread executor. */ + public GrpcExecutorProfile { + if (corePoolSize < 1 || maxPoolSize < corePoolSize) { + throw new IllegalArgumentException("pool sizes must be positive and ordered"); + } + if (queueCapacity < 1) { + throw new IllegalArgumentException( + "an unbounded queue defers overload rather than preventing it; work eventually runs that " + + "nobody is waiting for"); + } + if (queueCapacity > 10_000) { + throw new IllegalArgumentException( + "a queue of " + + queueCapacity + + " is unbounded in practice; nothing in it will finish " + + "inside a caller's deadline"); + } + if (keepAlive == null || keepAlive.isNegative()) { + throw new IllegalArgumentException("a keep-alive must be present and non-negative"); + } + } + + /** The Stable default: a bounded platform-thread pool. */ + public static GrpcExecutorProfile boundedPool(int maxPoolSize, int queueCapacity) { + return new GrpcExecutorProfile( + Math.max(1, maxPoolSize / 4), maxPoolSize, queueCapacity, Duration.ofSeconds(60), false); + } + + /** + * A virtual-thread executor, still with a bounded admission queue. + * + *

Virtual threads remove the thread-count bound, not the need for one: the database pool, the + * downstream service and the memory are all still finite, and admission is where that is + * enforced. + */ + public static GrpcExecutorProfile virtualThreads(int queueCapacity) { + return new GrpcExecutorProfile(1, Integer.MAX_VALUE, queueCapacity, Duration.ZERO, true); + } + + /** The largest number of calls that can be in the executor at once. */ + public int admissionCeiling() { + return virtualThreads ? queueCapacity : maxPoolSize + queueCapacity; + } +} diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcNettyParityContract.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcNettyParityContract.java new file mode 100644 index 00000000..9bf4b0b9 --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcNettyParityContract.java @@ -0,0 +1,69 @@ +package dev.caskeleton.grpc.server; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * The capabilities both Netty variants must demonstrate identically. + * + *

A list rather than a sentence, because "shaded is equivalent" is a claim that has to be + * re-established every time either artifact is upgraded, and a claim nobody can enumerate is a + * claim nobody re-checks. Each entry names something a contract suite runs on both variants. + */ +public final class GrpcNettyParityContract { + + /** What both variants must do the same way. */ + public enum Capability { + /** Server-authenticated TLS. */ + TLS, + /** Mutual TLS with a client certificate. */ + MUTUAL_TLS, + /** The inbound metadata hard limit, enforced by the transport. */ + METADATA_LIMIT, + /** The inbound message hard limit, enforced by the transport. */ + MESSAGE_LIMIT, + /** The standard health service. */ + HEALTH, + /** Server reflection. */ + REFLECTION, + /** Graceful shutdown, including GOAWAY and in-flight drain. */ + GRACEFUL_SHUTDOWN, + /** Keepalive negotiation in both directions. */ + KEEPALIVE + } + + private GrpcNettyParityContract() {} + + /** Every capability that must hold on both variants. */ + public static Set requiredCapabilities() { + return Set.of(Capability.values()); + } + + /** + * Which capabilities have not been demonstrated on both variants. + * + * @return an empty list when parity is established + */ + public static List unproven( + Set provenOnUnshaded, Set provenOnShaded) { + if (provenOnUnshaded == null || provenOnShaded == null) { + throw new IllegalArgumentException("parity needs the evidence from both variants"); + } + List gaps = new ArrayList<>(); + for (Capability capability : Capability.values()) { + boolean unshaded = provenOnUnshaded.contains(capability); + boolean shaded = provenOnShaded.contains(capability); + if (!unshaded || !shaded) { + gaps.add( + capability + + " is unproven on " + + (!unshaded && !shaded + ? "either variant" + : (!unshaded ? GrpcNettyVariant.UNSHADED : GrpcNettyVariant.SHADED) + .toString())); + } + } + return List.copyOf(gaps); + } +} diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcNettyVariant.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcNettyVariant.java new file mode 100644 index 00000000..d1871072 --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcNettyVariant.java @@ -0,0 +1,32 @@ +package dev.caskeleton.grpc.server; + +/** + * Which Netty artifact the transport uses. + * + *

Shaded exists to avoid a version conflict with an application's own Netty, and it adds nothing + * else. Saying so here matters because the shaded artifact is often reached for as if it were a + * different, safer transport; it is the same transport with its dependencies renamed, and anything + * that works on one must work identically on the other. + */ +public enum GrpcNettyVariant { + /** {@code io.grpc:grpc-netty} against the application's own Netty. */ + UNSHADED("io.grpc.netty"), + /** {@code io.grpc:grpc-netty-shaded}, with Netty vendored and relocated. */ + SHADED("io.grpc.netty.shaded.io.grpc.netty"); + + private final String builderPackage; + + GrpcNettyVariant(String builderPackage) { + this.builderPackage = builderPackage; + } + + /** The package its builders live in, which is how the two are told apart on a classpath. */ + public String builderPackage() { + return builderPackage; + } + + /** The transport this variant produces. */ + public GrpcServerTransport transport() { + return this == SHADED ? GrpcServerTransport.NETTY_SHADED : GrpcServerTransport.NETTY; + } +} diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcNettyVariantSelector.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcNettyVariantSelector.java new file mode 100644 index 00000000..91a43cd6 --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcNettyVariantSelector.java @@ -0,0 +1,52 @@ +package dev.caskeleton.grpc.server; + +import java.util.Set; + +/** + * Picks exactly one Netty variant, and refuses a runtime that has both. + * + *

Both on one classpath is not a redundancy. Two Netty stacks mean two sets of event loops, two + * allocators and two direct-memory budgets, and a channel built by one cannot be served by the + * other; the symptom is usually a {@code NoClassDefFoundError} deep in a builder, at startup, on + * the one deployment that pulled in a transitive dependency nobody looked at. + */ +public final class GrpcNettyVariantSelector { + + private GrpcNettyVariantSelector() {} + + /** + * Selects the variant to use. + * + * @param availableVariants which variants are on the classpath + * @param preferred the variant the deployment asked for, or null to take whatever is present + * @throws IllegalStateException when both are present, or when neither is, or when the preferred + * one is absent + */ + public static GrpcNettyVariant select( + Set availableVariants, GrpcNettyVariant preferred) { + if (availableVariants == null || availableVariants.isEmpty()) { + throw new IllegalStateException( + "no Netty transport is on the classpath; Netty is the Stable certification transport"); + } + if (availableVariants.size() > 1) { + throw new IllegalStateException( + "both " + + GrpcNettyVariant.UNSHADED + + " and " + + GrpcNettyVariant.SHADED + + " are on the classpath. Two Netty stacks mean two event-loop groups and two direct " + + "memory budgets, and a channel built by one cannot be served by the other. Exclude " + + "one."); + } + GrpcNettyVariant present = availableVariants.iterator().next(); + if (preferred != null && preferred != present) { + throw new IllegalStateException( + "the deployment asked for " + + preferred + + " but only " + + present + + " is on the classpath"); + } + return present; + } +} diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcResponseMapper.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcResponseMapper.java new file mode 100644 index 00000000..1a5aab09 --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcResponseMapper.java @@ -0,0 +1,18 @@ +package dev.caskeleton.grpc.server; + +/** + * Turns an application result into a response message. + * + *

Separate from the invocation so that mapping cannot quietly acquire a side effect. A mapper + * that also loads something is a use case with no transaction; keeping it a pure function of the + * result is what makes "the adapter does not do business work" true rather than intended. + * + * @param the application result type + * @param the response message type + */ +@FunctionalInterface +public interface GrpcResponseMapper { + + /** Maps an application result to a response message. */ + S toResponse(R result); +} diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServerInterceptorChain.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServerInterceptorChain.java new file mode 100644 index 00000000..47e62659 --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServerInterceptorChain.java @@ -0,0 +1,110 @@ +package dev.caskeleton.grpc.server; + +import io.grpc.ServerInterceptor; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** + * Builds the server interceptor chain in the Stable order and hands it over in the order gRPC + * actually wants. + * + *

That reversal is the reason this class exists rather than a list literal at the call site. + * {@code ServerInterceptors.intercept} wraps each interceptor around the previous one, so the last + * one passed is the outermost at runtime — the opposite of how the order reads. Every codebase that + * builds this list by hand gets it backwards at least once, and the symptom is an exception + * boundary that catches nothing. + */ +public final class GrpcServerInterceptorChain { + + private final Map byStage; + + private GrpcServerInterceptorChain(Map byStage) { + this.byStage = new EnumMap<>(byStage); + } + + /** A builder. */ + public static Builder builder() { + return new Builder(); + } + + /** + * The interceptors in Stable order: outermost first, as the order reads. + * + *

Not what you pass to gRPC. See {@link #inGrpcRegistrationOrder()}. + */ + public List inStableOrder() { + List ordered = new ArrayList<>(); + for (GrpcServerInterceptorStage stage : GrpcServerInterceptorStage.values()) { + ServerInterceptor interceptor = byStage.get(stage); + if (interceptor != null) { + ordered.add(interceptor); + } + } + return List.copyOf(ordered); + } + + /** + * The interceptors reversed, which is what {@code ServerInterceptors.intercept} needs to produce + * the Stable order at runtime. + */ + public List inGrpcRegistrationOrder() { + List reversed = new ArrayList<>(inStableOrder()); + java.util.Collections.reverse(reversed); + return List.copyOf(reversed); + } + + /** The stages this chain covers. */ + public List stages() { + return inStageOrder(); + } + + private List inStageOrder() { + List stages = new ArrayList<>(); + for (GrpcServerInterceptorStage stage : GrpcServerInterceptorStage.values()) { + if (byStage.containsKey(stage)) { + stages.add(stage); + } + } + return List.copyOf(stages); + } + + /** Accumulates one interceptor per stage and validates the set at build time. */ + public static final class Builder { + + private final Map byStage = + new EnumMap<>(GrpcServerInterceptorStage.class); + + private Builder() {} + + /** + * Registers the interceptor for one stage. + * + * @throws IllegalArgumentException on a second interceptor for the same stage + */ + public Builder stage(GrpcServerInterceptorStage stage, ServerInterceptor interceptor) { + if (stage == null || interceptor == null) { + throw new IllegalArgumentException( + "a stage registration needs both a stage and an interceptor"); + } + if (byStage.putIfAbsent(stage, interceptor) != null) { + throw new IllegalArgumentException( + "stage " + + stage + + " already has an interceptor; two would run in an order nobody declared"); + } + return this; + } + + /** + * Builds the chain. + * + * @throws IllegalStateException when a required stage is missing + */ + public GrpcServerInterceptorChain build() { + GrpcServerInterceptorOrder.requireStableOrder(List.copyOf(byStage.keySet())); + return new GrpcServerInterceptorChain(byStage); + } + } +} diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServerInterceptorOrder.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServerInterceptorOrder.java new file mode 100644 index 00000000..76a27391 --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServerInterceptorOrder.java @@ -0,0 +1,90 @@ +package dev.caskeleton.grpc.server; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Validates a proposed interceptor chain against the Stable order. + * + *

Checked at startup rather than trusted, because the failure is silent in both directions. A + * chain with validation before authentication lets an anonymous caller probe the schema through + * error messages; one with the exception boundary in the middle lets a throwable from an earlier + * stage escape as {@code UNKNOWN}. Neither shows up in a test of the happy path. + */ +public final class GrpcServerInterceptorOrder { + + private GrpcServerInterceptorOrder() {} + + /** The Stable order. */ + public static List stableOrder() { + return List.of(GrpcServerInterceptorStage.values()); + } + + /** + * Every problem with {@code proposed}. + * + * @return an empty list when the chain is exactly the Stable order, minus optional stages + */ + public static List violations(List proposed) { + if (proposed == null) { + throw new IllegalArgumentException("a proposed chain must not be null"); + } + List violations = new ArrayList<>(); + + Set seen = new LinkedHashSet<>(); + proposed.forEach( + stage -> { + if (!seen.add(stage)) { + violations.add( + "stage " + + stage + + " appears more than once; a stage that runs twice has a " + + "different effect the second time and nothing says which one counted"); + } + }); + + EnumSet missing = EnumSet.allOf(GrpcServerInterceptorStage.class); + missing.removeAll(seen); + missing.stream() + .filter(GrpcServerInterceptorStage::required) + .forEach(stage -> violations.add("required stage " + stage + " is missing")); + + // Positions come from the published list rather than from Enum.ordinal(). The order is a + // contract this class publishes; reading the check from the same list means the two cannot + // disagree if the enum is ever reordered without the list being reviewed. + List order = stableOrder(); + int previousPosition = -1; + for (GrpcServerInterceptorStage stage : proposed) { + int position = order.indexOf(stage); + if (position < previousPosition) { + violations.add( + "stage " + stage + " runs after a later stage; the Stable order is " + order); + break; + } + previousPosition = position; + } + + if (!proposed.isEmpty() && proposed.get(0) != GrpcServerInterceptorStage.EXCEPTION_BOUNDARY) { + violations.add( + "the exception boundary must be outermost; anything thrown by an earlier stage escapes " + + "as an unmapped status"); + } + return List.copyOf(violations); + } + + /** + * Fails when the chain is not the Stable order. + * + * @throws IllegalStateException naming every problem + */ + public static void requireStableOrder(List proposed) { + List violations = violations(proposed); + if (!violations.isEmpty()) { + throw new IllegalStateException( + "server interceptor chain is not the Stable order: " + violations); + } + } +} diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServerInterceptorStage.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServerInterceptorStage.java new file mode 100644 index 00000000..c01b3bd0 --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServerInterceptorStage.java @@ -0,0 +1,51 @@ +package dev.caskeleton.grpc.server; + +/** + * The stages of the Stable server interceptor chain, in the order they must run. + * + *

Declaration order is the contract, and each position has a reason. The exception boundary is + * outermost so that a failure in any later stage still becomes a mapped status rather than an + * uncaught throwable. Authentication precedes actor and tenant resolution, which precedes + * authorization, because each needs the previous one's answer. Admission comes before deadline so a + * server under load sheds work before spending any. Idempotency precedes validation so that a + * replayed request returns its stored outcome without re-validating a body it already accepted. + * Validation is last before the adapter so the use case is handed a message it can trust. + */ +public enum GrpcServerInterceptorStage { + /** Outermost. Turns anything thrown downstream into a mapped status. */ + EXCEPTION_BOUNDARY(true), + /** Starts the trace, so every later stage's work is attributed to it. */ + TRACE(true), + /** Establishes that the caller is who it claims to be. */ + AUTHENTICATION(true), + /** Resolves the verified actor and tenant. */ + ACTOR_TENANT(true), + /** Decides whether this caller may invoke this method. */ + AUTHORIZATION(true), + /** Sheds load before any work is spent on the call. */ + ADMISSION(true), + /** Computes the effective deadline and binds the cancellation token. */ + DEADLINE_CANCELLATION(true), + /** Claims or replays the operation for methods that require a key. */ + IDEMPOTENCY(false), + /** Rejects a malformed message before the use case sees it. */ + VALIDATION(true), + /** The service adapter itself. */ + SERVICE_ADAPTER(true); + + private final boolean required; + + GrpcServerInterceptorStage(boolean required) { + this.required = required; + } + + /** + * Whether every server must have this stage. + * + *

Only idempotency is optional, because a server with no state-changing keyed methods has + * nothing for it to do. Everything else missing is a hole rather than a configuration. + */ + public boolean required() { + return required; + } +} diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServerProfile.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServerProfile.java new file mode 100644 index 00000000..6c75ff8d --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServerProfile.java @@ -0,0 +1,84 @@ +package dev.caskeleton.grpc.server; + +import java.time.Duration; + +/** + * The server's transport settings, as one reviewed value rather than a builder call chain. + * + *

Every field here has a default in gRPC that is wrong for a long-lived service in at least one + * deployment. Unlimited connection age keeps a client pinned to one instance across a whole + * rollout; no keepalive lets an idle connection be silently dropped by an intermediary and + * discovered only by the next request; a four-megabyte message limit is a number nobody chose for + * this service. Stating them makes each one a decision with an owner. + */ +public record GrpcServerProfile( + GrpcServerTransport transport, + long maxInboundMessageBytes, + int maxInboundMetadataBytes, + Duration keepAliveTime, + Duration keepAliveTimeout, + Duration permitKeepAliveTime, + Duration maxConnectionAge, + Duration maxConnectionAgeGrace, + GrpcExecutorProfile executor) { + + /** Refuses settings that would leave a hole the defaults already leave. */ + public GrpcServerProfile { + if (transport == null || executor == null) { + throw new IllegalArgumentException("a server profile names its transport and executor"); + } + if (!transport.production()) { + throw new IllegalArgumentException( + transport + + " is not a production transport; in-process evidence is not network evidence"); + } + if (maxInboundMessageBytes < 1L || maxInboundMetadataBytes < 1) { + throw new IllegalArgumentException("message and metadata bounds must be positive"); + } + requirePositive(keepAliveTime, "keep-alive time"); + requirePositive(keepAliveTimeout, "keep-alive timeout"); + requirePositive(permitKeepAliveTime, "permitted client keep-alive time"); + requirePositive(maxConnectionAge, "max connection age"); + requirePositive(maxConnectionAgeGrace, "max connection age grace"); + if (keepAliveTimeout.compareTo(keepAliveTime) >= 0) { + throw new IllegalArgumentException( + "a keep-alive timeout at or above the interval declares a connection dead before the " + + "next probe could answer"); + } + if (permitKeepAliveTime.compareTo(keepAliveTime) > 0) { + throw new IllegalArgumentException( + "permitting clients to ping less often than the server does means the server's own " + + "probes are what keeps the connection alive"); + } + if (maxConnectionAgeGrace.compareTo(maxConnectionAge) > 0) { + throw new IllegalArgumentException("the grace period outlives the connection age it follows"); + } + } + + private static void requirePositive(Duration value, String what) { + if (value == null || value.isZero() || value.isNegative()) { + throw new IllegalArgumentException( + what + + " must be positive; leaving it at the default is how a connection outlives a rollout"); + } + } + + /** The Stable default profile on shaded Netty. */ + public static GrpcServerProfile stableNetty(GrpcExecutorProfile executor) { + return new GrpcServerProfile( + GrpcServerTransport.NETTY_SHADED, + 1024L * 1024L, + 8192, + Duration.ofSeconds(30), + Duration.ofSeconds(10), + Duration.ofSeconds(30), + Duration.ofMinutes(30), + Duration.ofSeconds(30), + executor); + } + + /** How many calls this server will hold at once. */ + public int admissionCeiling() { + return executor.admissionCeiling(); + } +} diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServerTransport.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServerTransport.java new file mode 100644 index 00000000..9249c489 --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServerTransport.java @@ -0,0 +1,38 @@ +package dev.caskeleton.grpc.server; + +/** + * Which transport a server runs on, and what that transport's evidence is worth. + * + *

{@link #IN_PROCESS} exists so that a fast contract test has somewhere to run, and {@link + * #certifiesNetworkBehaviour()} exists so that its results cannot be mistaken for network evidence. + * In-process skips HTTP/2 framing, TLS, metadata and message hard limits, keepalive and GOAWAY + * entirely; a suite that passes there has tested the adapter, not the transport. + */ +public enum GrpcServerTransport { + /** Netty with an unshaded dependency. The Stable certification transport. */ + NETTY(true, true), + /** Netty vendored inside the gRPC artifact, for dependency conflict avoidance. */ + NETTY_SHADED(true, true), + /** In-memory. Fast contract evidence only, never transport evidence. */ + IN_PROCESS(false, false), + /** A Servlet container owns the socket. A compatibility profile, not a Stable one. */ + SERVLET(false, true); + + private final boolean certifiesNetworkBehaviour; + private final boolean production; + + GrpcServerTransport(boolean certifiesNetworkBehaviour, boolean production) { + this.certifiesNetworkBehaviour = certifiesNetworkBehaviour; + this.production = production; + } + + /** Whether a result on this transport is evidence about HTTP/2, TLS and transport limits. */ + public boolean certifiesNetworkBehaviour() { + return certifiesNetworkBehaviour; + } + + /** Whether this transport may serve production traffic. */ + public boolean production() { + return production; + } +} diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServiceAdapter.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServiceAdapter.java new file mode 100644 index 00000000..749d2be4 --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServiceAdapter.java @@ -0,0 +1,92 @@ +package dev.caskeleton.grpc.server; + +import dev.caskeleton.grpc.context.GrpcRequestContext; +import dev.caskeleton.grpc.error.GrpcErrorMapper; +import dev.caskeleton.grpc.error.GrpcPlatformException; +import java.util.function.Function; + +/** + * The three steps a gRPC service adapter is allowed to take: map in, invoke, map out. + * + *

Composed from three functions rather than left as a base class to extend, because a base class + * has room for a fourth step. The failure this shape prevents is the adapter that grows a "just + * this one lookup" between mapping and invoking, which is a use case in a transport class with no + * transaction around it. + * + *

The adapter never builds a {@code Status}. Failures go to the shared {@link GrpcErrorMapper}, + * so one method cannot end up reporting a conflict as {@code INTERNAL} because whoever wrote it + * decided differently. + * + * @param the request message type + * @param the application command type + * @param the application result type + * @param the response message type + */ +public final class GrpcServiceAdapter { + + private final GrpcServiceAdapterDescriptor descriptor; + private final Function requestMapper; + private final GrpcApplicationInvocation invocation; + private final GrpcResponseMapper responseMapper; + private final GrpcErrorMapper errorMapper; + + /** Composes one adapter. */ + public GrpcServiceAdapter( + GrpcServiceAdapterDescriptor descriptor, + Function requestMapper, + GrpcApplicationInvocation invocation, + GrpcResponseMapper responseMapper, + GrpcErrorMapper errorMapper) { + if (descriptor == null + || requestMapper == null + || invocation == null + || responseMapper == null + || errorMapper == null) { + throw new IllegalArgumentException("a service adapter needs all five collaborators"); + } + this.descriptor = descriptor; + this.requestMapper = requestMapper; + this.invocation = invocation; + this.responseMapper = responseMapper; + this.errorMapper = errorMapper; + } + + /** What this adapter serves. */ + public GrpcServiceAdapterDescriptor descriptor() { + return descriptor; + } + + /** + * Runs the three steps. + * + * @throws IllegalStateException when the call is no longer worth serving, checked before the use + * case rather than after it + */ + public S handle(Q request, GrpcRequestContext context) { + if (context == null) { + throw new IllegalArgumentException("an adapter invocation needs a request context"); + } + if (!context.live()) { + throw new IllegalStateException( + "refusing to invoke '" + + descriptor.method().canonical() + + "': the call is cancelled or its deadline has passed"); + } + C command = requestMapper.apply(request); + R result = invocation.invoke(command, context); + return responseMapper.toResponse(result); + } + + /** + * The wire form of a failure, produced by the shared mapper. + * + *

Exposed here so an adapter's transport wrapper has somewhere to send a throwable without + * constructing a status of its own. + */ + public GrpcErrorMapper.MappedError mapFailure(Throwable failure) { + if (failure instanceof GrpcPlatformException platformFailure) { + return errorMapper.mapPlatformFailure(platformFailure); + } + return errorMapper.mapUnknown(failure); + } +} diff --git a/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServiceAdapterDescriptor.java b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServiceAdapterDescriptor.java new file mode 100644 index 00000000..5bba8847 --- /dev/null +++ b/src/grpc/grpc-server/src/main/java/dev/caskeleton/grpc/server/GrpcServiceAdapterDescriptor.java @@ -0,0 +1,48 @@ +package dev.caskeleton.grpc.server; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; + +/** + * What one service adapter is, declared rather than discovered. + * + *

{@link ExecutionModel} is here because getting it wrong is invisible until load arrives. A + * blocking use case run on the event loop works perfectly in a test and stalls every other call on + * the same connection in production; declaring the model is what lets the server refuse to run it + * there. + */ +public record GrpcServiceAdapterDescriptor( + GrpcMethodName method, RpcType rpcType, ExecutionModel executionModel) { + + /** How the adapter's work is executed. */ + public enum ExecutionModel { + /** Blocks its thread. Must run on a bounded executor, never the event loop. */ + BLOCKING(true), + /** Returns without blocking and completes elsewhere. */ + ASYNCHRONOUS(false); + + private final boolean requiresBoundedExecutor; + + ExecutionModel(boolean requiresBoundedExecutor) { + this.requiresBoundedExecutor = requiresBoundedExecutor; + } + + /** Whether this model must be handed off the transport thread. */ + public boolean requiresBoundedExecutor() { + return requiresBoundedExecutor; + } + } + + /** Requires all three parts. */ + public GrpcServiceAdapterDescriptor { + if (method == null || rpcType == null || executionModel == null) { + throw new IllegalArgumentException( + "an adapter descriptor states its method, shape and model"); + } + } + + /** A blocking unary adapter, which is what a use case over a database is. */ + public static GrpcServiceAdapterDescriptor blockingUnary(GrpcMethodName method) { + return new GrpcServiceAdapterDescriptor(method, RpcType.UNARY, ExecutionModel.BLOCKING); + } +} diff --git a/src/grpc/grpc-server/src/test/java/dev/caskeleton/grpc/architecture/GrpcApplicationBoundaryRulesTest.java b/src/grpc/grpc-server/src/test/java/dev/caskeleton/grpc/architecture/GrpcApplicationBoundaryRulesTest.java new file mode 100644 index 00000000..256d312a --- /dev/null +++ b/src/grpc/grpc-server/src/test/java/dev/caskeleton/grpc/architecture/GrpcApplicationBoundaryRulesTest.java @@ -0,0 +1,142 @@ +package dev.caskeleton.grpc.architecture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcApplicationBoundaryRulesTest { + + @Test + @DisplayName("an adapter reaching persistence, messaging or HTTP directly is refused") + void anAdapterMayNotReachInfrastructureDirectly() { + Set referenced = + Set.of( + "dev.caskeleton.application.document.CreateDocumentUseCase", + "jakarta.persistence.EntityManager", + "org.springframework.data.jpa.repository.JpaRepository", + "org.apache.kafka.clients.producer.KafkaProducer", + "java.net.http.HttpClient"); + + assertThat(GrpcApplicationBoundaryRules.violations("DocumentServiceAdapter", referenced)) + .hasSize(4) + .allSatisfy(violation -> assertThat(violation).startsWith("DocumentServiceAdapter")); + } + + @Test + @DisplayName("an adapter that only reaches application ports passes") + void anAdapterOnApplicationPortsPasses() { + assertThat( + GrpcApplicationBoundaryRules.violations( + "DocumentServiceAdapter", + Set.of( + "dev.caskeleton.application.document.CreateDocumentUseCase", + "dev.caskeleton.grpc.context.GrpcRequestContext"))) + .isEmpty(); + } + + @Test + @DisplayName("a raw DataSource or Connection is refused even though its package is not") + void exactTypesAreRefusedWherePackagesAreTooBroad() { + assertThat( + GrpcApplicationBoundaryRules.violations( + "DocumentServiceAdapter", Set.of("javax.sql.DataSource"))) + .singleElement() + .satisfies(violation -> assertThat(violation).contains("belongs behind a port")); + assertThat(GrpcApplicationBoundaryRules.forbiddenTypes()) + .contains("java.sql.Connection", "java.sql.DriverManager"); + } + + @Test + @DisplayName("an inbound adapter may not reach an outbound adapter") + void inboundMayNotReachOutbound() { + assertThat( + GrpcApplicationBoundaryRules.violations( + "DocumentServiceAdapter", + Set.of("dev.caskeleton.adapter.outbound.persistence.DocumentJpaRepository"))) + .hasSize(1); + } + + @Test + @DisplayName("ordinary application code may not import a raw channel, server or call builder") + void rawConstructionApisAreRefusedOutsideThePlatform() { + GrpcRawApiImportRule rule = GrpcRawApiImportRule.caSkeleton(); + String applicationSource = + """ + package dev.caskeleton.application.document; + + import io.grpc.ManagedChannelBuilder; + import java.util.List; + + class DocumentClient {} + """; + + assertThat(rule.violations("DocumentClient.java", applicationSource)) + .singleElement() + .satisfies( + violation -> + assertThat(violation) + .contains("io.grpc.ManagedChannelBuilder") + .contains("no interceptor chain")); + } + + @Test + @DisplayName("the platform's own packages and generated code may construct") + void thePlatformAndGeneratedCodeMayConstruct() { + GrpcRawApiImportRule rule = GrpcRawApiImportRule.caSkeleton(); + String platformSource = + """ + package dev.caskeleton.grpc.client; + + import io.grpc.ManagedChannelBuilder; + + class ChannelFactory {} + """; + String generatedSource = + """ + package hyeonworks.grpc.common.v1.generated; + + import io.grpc.MethodDescriptor; + + public final class DocumentServiceGrpc {} + """; + + assertThat(rule.violations("ChannelFactory.java", platformSource)).isEmpty(); + assertThat(rule.violations("DocumentServiceGrpc.java", generatedSource)).isEmpty(); + assertThat(rule.allowed("dev.caskeleton.grpc.server")).isTrue(); + assertThat(rule.allowed("dev.caskeleton.application")).isFalse(); + } + + @Test + @DisplayName("a rule with no allowed package is refused, since the platform must build somewhere") + void aRuleWithNoAllowedPackageIsRefused() { + assertThatThrownBy(() -> new GrpcRawApiImportRule(Set.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("build channels somewhere"); + } + + @Test + @DisplayName("the raw API list covers channel, server and call construction on every transport") + void theRawApiListCoversEveryConstructionPath() { + assertThat(GrpcRawApiImportRule.rawApiTypes()) + .contains( + "io.grpc.ManagedChannelBuilder", + "io.grpc.ServerBuilder", + "io.grpc.ClientCall", + "io.grpc.netty.NettyServerBuilder", + "io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder", + "io.grpc.inprocess.InProcessServerBuilder"); + } + + @Test + @DisplayName("the marker annotation is visible at runtime, so a rule can enumerate adapters") + void theMarkerIsVisibleAtRuntime() { + assertThat( + GrpcServiceAdapterMarker.class + .getAnnotation(java.lang.annotation.Retention.class) + .value()) + .isEqualTo(java.lang.annotation.RetentionPolicy.RUNTIME); + } +} diff --git a/src/grpc/grpc-server/src/test/java/dev/caskeleton/grpc/server/GrpcNettyVariantSelectorTest.java b/src/grpc/grpc-server/src/test/java/dev/caskeleton/grpc/server/GrpcNettyVariantSelectorTest.java new file mode 100644 index 00000000..a1519f8b --- /dev/null +++ b/src/grpc/grpc-server/src/test/java/dev/caskeleton/grpc/server/GrpcNettyVariantSelectorTest.java @@ -0,0 +1,94 @@ +package dev.caskeleton.grpc.server; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcNettyVariantSelectorTest { + + @Test + @DisplayName("exactly one Netty variant may be on the classpath") + void exactlyOneNettyVariantIsSelected() { + assertThat(GrpcNettyVariantSelector.select(Set.of(GrpcNettyVariant.SHADED), null)) + .isEqualTo(GrpcNettyVariant.SHADED); + assertThat(GrpcNettyVariantSelector.select(Set.of(GrpcNettyVariant.UNSHADED), null)) + .isEqualTo(GrpcNettyVariant.UNSHADED); + } + + @Test + @DisplayName("both variants on one classpath is refused, with the reason") + void bothVariantsAreRefused() { + assertThatThrownBy( + () -> + GrpcNettyVariantSelector.select( + Set.of(GrpcNettyVariant.SHADED, GrpcNettyVariant.UNSHADED), null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("two event-loop groups") + .hasMessageContaining("two direct memory budgets"); + } + + @Test + @DisplayName("no Netty at all is refused, since Netty is the certification transport") + void noNettyIsRefused() { + assertThatThrownBy(() -> GrpcNettyVariantSelector.select(Set.of(), null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("certification transport"); + } + + @Test + @DisplayName("asking for a variant that is not present is refused rather than silently swapped") + void aPreferenceThatCannotBeMetIsRefused() { + assertThatThrownBy( + () -> + GrpcNettyVariantSelector.select( + Set.of(GrpcNettyVariant.SHADED), GrpcNettyVariant.UNSHADED)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("asked for"); + } + + @Test + @DisplayName("the two variants are the same transport, told apart by their builder package") + void theVariantsAreTheSameTransport() { + assertThat(GrpcNettyVariant.UNSHADED.builderPackage()).isEqualTo("io.grpc.netty"); + assertThat(GrpcNettyVariant.SHADED.builderPackage()) + .isEqualTo("io.grpc.netty.shaded.io.grpc.netty"); + assertThat(GrpcNettyVariant.SHADED.transport()).isEqualTo(GrpcServerTransport.NETTY_SHADED); + assertThat(GrpcNettyVariant.UNSHADED.transport()).isEqualTo(GrpcServerTransport.NETTY); + } + + @Test + @DisplayName("parity is enumerated rather than asserted") + void parityIsEnumerated() { + assertThat(GrpcNettyParityContract.requiredCapabilities()) + .contains( + GrpcNettyParityContract.Capability.TLS, + GrpcNettyParityContract.Capability.MUTUAL_TLS, + GrpcNettyParityContract.Capability.METADATA_LIMIT, + GrpcNettyParityContract.Capability.MESSAGE_LIMIT, + GrpcNettyParityContract.Capability.HEALTH, + GrpcNettyParityContract.Capability.REFLECTION, + GrpcNettyParityContract.Capability.GRACEFUL_SHUTDOWN, + GrpcNettyParityContract.Capability.KEEPALIVE); + } + + @Test + @DisplayName("a capability proven on only one variant is reported as unproven, naming which") + void oneSidedEvidenceIsUnproven() { + assertThat( + GrpcNettyParityContract.unproven( + GrpcNettyParityContract.requiredCapabilities(), + Set.of(GrpcNettyParityContract.Capability.TLS))) + .isNotEmpty() + .allSatisfy(gap -> assertThat(gap).contains("unproven on")); + assertThat(GrpcNettyParityContract.unproven(Set.of(), Set.of())) + .allSatisfy(gap -> assertThat(gap).contains("either variant")); + assertThat( + GrpcNettyParityContract.unproven( + GrpcNettyParityContract.requiredCapabilities(), + GrpcNettyParityContract.requiredCapabilities())) + .isEmpty(); + } +} diff --git a/src/grpc/grpc-server/src/test/java/dev/caskeleton/grpc/server/GrpcServerInterceptorOrderTest.java b/src/grpc/grpc-server/src/test/java/dev/caskeleton/grpc/server/GrpcServerInterceptorOrderTest.java new file mode 100644 index 00000000..abc80acc --- /dev/null +++ b/src/grpc/grpc-server/src/test/java/dev/caskeleton/grpc/server/GrpcServerInterceptorOrderTest.java @@ -0,0 +1,165 @@ +package dev.caskeleton.grpc.server; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcServerInterceptorOrderTest { + + /** A named no-op interceptor, so a chain's contents are identifiable in an assertion. */ + private record NamedInterceptor(String name) implements ServerInterceptor { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + return next.startCall(call, headers); + } + } + + private static GrpcServerInterceptorChain.Builder completeChain() { + GrpcServerInterceptorChain.Builder builder = GrpcServerInterceptorChain.builder(); + for (GrpcServerInterceptorStage stage : GrpcServerInterceptorStage.values()) { + builder.stage(stage, new NamedInterceptor(stage.name())); + } + return builder; + } + + @Test + @DisplayName("the Stable order runs exception boundary first and the service adapter last") + void theStableOrderIsExceptionBoundaryFirstAndAdapterLast() { + List order = GrpcServerInterceptorOrder.stableOrder(); + + assertThat(order.get(0)).isEqualTo(GrpcServerInterceptorStage.EXCEPTION_BOUNDARY); + assertThat(order.get(order.size() - 1)).isEqualTo(GrpcServerInterceptorStage.SERVICE_ADAPTER); + assertThat(order) + .containsSubsequence( + GrpcServerInterceptorStage.AUTHENTICATION, + GrpcServerInterceptorStage.ACTOR_TENANT, + GrpcServerInterceptorStage.AUTHORIZATION) + .containsSubsequence( + GrpcServerInterceptorStage.IDEMPOTENCY, + GrpcServerInterceptorStage.VALIDATION, + GrpcServerInterceptorStage.SERVICE_ADAPTER); + } + + @Test + @DisplayName("a complete chain in Stable order passes") + void aCompleteChainPasses() { + assertThat(GrpcServerInterceptorOrder.violations(GrpcServerInterceptorOrder.stableOrder())) + .isEmpty(); + } + + @Test + @DisplayName("a missing required stage is reported, and idempotency alone may be absent") + void missingRequiredStagesAreReported() { + List withoutIdempotency = + GrpcServerInterceptorOrder.stableOrder().stream() + .filter(stage -> stage != GrpcServerInterceptorStage.IDEMPOTENCY) + .toList(); + List withoutAuthentication = + GrpcServerInterceptorOrder.stableOrder().stream() + .filter(stage -> stage != GrpcServerInterceptorStage.AUTHENTICATION) + .toList(); + + assertThat(GrpcServerInterceptorOrder.violations(withoutIdempotency)).isEmpty(); + assertThat(GrpcServerInterceptorOrder.violations(withoutAuthentication)) + .anySatisfy(violation -> assertThat(violation).contains("AUTHENTICATION is missing")); + } + + @Test + @DisplayName("a duplicated stage is reported") + void aDuplicatedStageIsReported() { + List duplicated = + new ArrayList<>(GrpcServerInterceptorOrder.stableOrder()); + duplicated.add(GrpcServerInterceptorStage.VALIDATION); + + assertThat(GrpcServerInterceptorOrder.violations(duplicated)) + .anySatisfy(violation -> assertThat(violation).contains("more than once")); + } + + @Test + @DisplayName("validation before authentication is refused") + void anOutOfOrderChainIsRefused() { + List reordered = + List.of( + GrpcServerInterceptorStage.EXCEPTION_BOUNDARY, + GrpcServerInterceptorStage.TRACE, + GrpcServerInterceptorStage.VALIDATION, + GrpcServerInterceptorStage.AUTHENTICATION, + GrpcServerInterceptorStage.ACTOR_TENANT, + GrpcServerInterceptorStage.AUTHORIZATION, + GrpcServerInterceptorStage.ADMISSION, + GrpcServerInterceptorStage.DEADLINE_CANCELLATION, + GrpcServerInterceptorStage.SERVICE_ADAPTER); + + assertThat(GrpcServerInterceptorOrder.violations(reordered)) + .anySatisfy(violation -> assertThat(violation).contains("runs after a later stage")); + } + + @Test + @DisplayName("an exception boundary that is not outermost is refused") + void theExceptionBoundaryMustBeOutermost() { + List misplaced = + List.of( + GrpcServerInterceptorStage.TRACE, + GrpcServerInterceptorStage.EXCEPTION_BOUNDARY, + GrpcServerInterceptorStage.AUTHENTICATION, + GrpcServerInterceptorStage.ACTOR_TENANT, + GrpcServerInterceptorStage.AUTHORIZATION, + GrpcServerInterceptorStage.ADMISSION, + GrpcServerInterceptorStage.DEADLINE_CANCELLATION, + GrpcServerInterceptorStage.VALIDATION, + GrpcServerInterceptorStage.SERVICE_ADAPTER); + + assertThat(GrpcServerInterceptorOrder.violations(misplaced)) + .anySatisfy(violation -> assertThat(violation).contains("must be outermost")); + } + + @Test + @DisplayName("registering two interceptors for one stage is refused") + void twoInterceptorsForOneStageAreRefused() { + GrpcServerInterceptorChain.Builder builder = + GrpcServerInterceptorChain.builder() + .stage(GrpcServerInterceptorStage.TRACE, new NamedInterceptor("first")); + + assertThatThrownBy( + () -> builder.stage(GrpcServerInterceptorStage.TRACE, new NamedInterceptor("second"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("already has an interceptor"); + } + + @Test + @DisplayName("the registration order handed to gRPC is the reverse of the Stable order") + void registrationOrderIsReversed() { + GrpcServerInterceptorChain chain = completeChain().build(); + + List stable = + chain.inStableOrder().stream().map(i -> ((NamedInterceptor) i).name()).toList(); + List registration = + chain.inGrpcRegistrationOrder().stream().map(i -> ((NamedInterceptor) i).name()).toList(); + + assertThat(stable.get(0)).isEqualTo(GrpcServerInterceptorStage.EXCEPTION_BOUNDARY.name()); + assertThat(registration.get(registration.size() - 1)) + .isEqualTo(GrpcServerInterceptorStage.EXCEPTION_BOUNDARY.name()); + assertThat(registration).containsExactlyElementsOf(stable.reversed()); + } + + @Test + @DisplayName("a chain missing a required stage fails to build") + void anIncompleteChainFailsToBuild() { + GrpcServerInterceptorChain.Builder builder = + GrpcServerInterceptorChain.builder() + .stage(GrpcServerInterceptorStage.EXCEPTION_BOUNDARY, new NamedInterceptor("boundary")); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not the Stable order"); + } +} diff --git a/src/grpc/grpc-server/src/test/java/dev/caskeleton/grpc/server/GrpcServerProfileTest.java b/src/grpc/grpc-server/src/test/java/dev/caskeleton/grpc/server/GrpcServerProfileTest.java new file mode 100644 index 00000000..461a0a04 --- /dev/null +++ b/src/grpc/grpc-server/src/test/java/dev/caskeleton/grpc/server/GrpcServerProfileTest.java @@ -0,0 +1,141 @@ +package dev.caskeleton.grpc.server; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcServerProfileTest { + + private static final GrpcExecutorProfile EXECUTOR = GrpcExecutorProfile.boundedPool(32, 256); + + @Test + @DisplayName("Netty is the Stable transport and in-process may not serve production") + void nettyIsTheStableTransport() { + assertThat(GrpcServerProfile.stableNetty(EXECUTOR).transport()) + .isEqualTo(GrpcServerTransport.NETTY_SHADED); + assertThat(GrpcServerTransport.NETTY.certifiesNetworkBehaviour()).isTrue(); + assertThat(GrpcServerTransport.IN_PROCESS.certifiesNetworkBehaviour()).isFalse(); + assertThat(GrpcServerTransport.SERVLET.certifiesNetworkBehaviour()).isFalse(); + } + + @Test + @DisplayName("an in-process production profile is refused") + void inProcessMayNotServeProduction() { + assertThatThrownBy( + () -> + new GrpcServerProfile( + GrpcServerTransport.IN_PROCESS, + 1024L, + 1024, + Duration.ofSeconds(30), + Duration.ofSeconds(10), + Duration.ofSeconds(30), + Duration.ofMinutes(30), + Duration.ofSeconds(30), + EXECUTOR)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not network evidence"); + } + + @Test + @DisplayName("an unbounded executor queue is refused") + void unboundedQueuesAreRefused() { + assertThatThrownBy(() -> GrpcExecutorProfile.boundedPool(32, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("defers overload"); + assertThatThrownBy(() -> GrpcExecutorProfile.boundedPool(32, 50_000)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unbounded in practice"); + } + + @Test + @DisplayName("virtual threads still carry a bounded admission queue") + void virtualThreadsStillNeedAdmissionBounds() { + GrpcExecutorProfile virtual = GrpcExecutorProfile.virtualThreads(512); + + assertThat(virtual.virtualThreads()).isTrue(); + assertThat(virtual.admissionCeiling()).isEqualTo(512); + assertThat(EXECUTOR.admissionCeiling()).isEqualTo(32 + 256); + } + + @Test + @DisplayName("a keep-alive timeout at or above the interval is refused") + void anIncoherentKeepAliveIsRefused() { + assertThatThrownBy( + () -> + new GrpcServerProfile( + GrpcServerTransport.NETTY, + 1024L, + 1024, + Duration.ofSeconds(10), + Duration.ofSeconds(10), + Duration.ofSeconds(10), + Duration.ofMinutes(30), + Duration.ofSeconds(30), + EXECUTOR)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("before the next probe could answer"); + } + + @Test + @DisplayName("an unset connection age is refused rather than left at the default") + void connectionAgeMustBeChosen() { + assertThatThrownBy( + () -> + new GrpcServerProfile( + GrpcServerTransport.NETTY, + 1024L, + 1024, + Duration.ofSeconds(30), + Duration.ofSeconds(10), + Duration.ofSeconds(30), + Duration.ZERO, + Duration.ofSeconds(30), + EXECUTOR)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("outlives a rollout"); + } + + @Test + @DisplayName("a call beyond the concurrency and queue bounds is refused, not queued forever") + void admissionRefusesRatherThanQueueingForever() { + GrpcAdmissionController controller = new GrpcAdmissionController(2, 1); + + assertThat(controller.tryAdmit().admitted()).isTrue(); + assertThat(controller.tryAdmit().admitted()).isTrue(); + assertThat(controller.tryAdmit().admitted()).isTrue(); + GrpcAdmissionController.Decision refused = controller.tryAdmit(); + + assertThat(refused.admitted()).isFalse(); + assertThat(refused.reason()).contains("at capacity"); + assertThat(controller.rejected()).isEqualTo(1); + assertThat(controller.inFlight()).isEqualTo(2); + assertThat(controller.queued()).isEqualTo(1); + } + + @Test + @DisplayName("a released call makes room, and a queued call is promoted into it") + void releaseAndPromotionTrackCapacity() { + GrpcAdmissionController controller = new GrpcAdmissionController(1, 1); + controller.tryAdmit(); + controller.tryAdmit(); + + controller.release(); + controller.promoteFromQueue(); + + assertThat(controller.inFlight()).isEqualTo(1); + assertThat(controller.queued()).isZero(); + } + + @Test + @DisplayName("an admission controller sized from an executor matches its ceiling") + void admissionIsSizedFromTheExecutor() { + GrpcAdmissionController controller = GrpcAdmissionController.forExecutor(EXECUTOR); + + assertThat(controller.inFlight()).isZero(); + assertThat(controller.tryAdmit().admitted()).isTrue(); + } +} diff --git a/src/grpc/grpc-server/src/test/java/dev/caskeleton/grpc/server/GrpcServiceAdapterTest.java b/src/grpc/grpc-server/src/test/java/dev/caskeleton/grpc/server/GrpcServiceAdapterTest.java new file mode 100644 index 00000000..015e10be --- /dev/null +++ b/src/grpc/grpc-server/src/test/java/dev/caskeleton/grpc/server/GrpcServiceAdapterTest.java @@ -0,0 +1,188 @@ +package dev.caskeleton.grpc.server; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.context.GrpcClientIdentity; +import dev.caskeleton.grpc.context.GrpcMetadataBudget; +import dev.caskeleton.grpc.context.GrpcRequestContext; +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.GrpcStatusCode; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.deadline.GrpcCancellationToken; +import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; +import dev.caskeleton.grpc.error.GrpcCompletionOutcome; +import dev.caskeleton.grpc.error.GrpcErrorMapper; +import dev.caskeleton.grpc.error.GrpcFailureCategory; +import dev.caskeleton.grpc.error.GrpcFailureContext; +import dev.caskeleton.grpc.error.GrpcPlatformException; +import dev.caskeleton.grpc.evidence.GrpcExecutionEvidence; +import io.grpc.Status; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcServiceAdapterTest { + + private static final GrpcMethodName CREATE = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument"); + + private record CreateRequest(String title) {} + + private record CreateCommand(String title) {} + + private record CreateResult(String documentId) {} + + private record CreateResponse(String documentId) {} + + private final GrpcErrorMapper errorMapper = new GrpcErrorMapper("document.v1", () -> "exec-0001"); + + private static GrpcRequestContext context(GrpcCancellationToken token, Duration remaining) { + return GrpcRequestContext.create( + CREATE, + RpcType.UNARY, + GrpcClientIdentity.fromVerifiedAuthentication("actor-1", "tenant-1", "jwt-issuer"), + new GrpcDeadlineBudget(remaining, GrpcDeadlineProfile.of(Duration.ofSeconds(2))), + token, + Map.of(), + Set.of(), + GrpcMetadataBudget.standard(), + null); + } + + private GrpcServiceAdapter adapter( + AtomicReference observed) { + return new GrpcServiceAdapter<>( + GrpcServiceAdapterDescriptor.blockingUnary(CREATE), + request -> new CreateCommand(request.title()), + (command, requestContext) -> { + observed.set(requestContext); + return new CreateResult("document-42"); + }, + result -> new CreateResponse(result.documentId()), + errorMapper); + } + + @Test + @DisplayName("the adapter maps in, invokes the use case, and maps out") + void theAdapterRunsExactlyThreeSteps() { + AtomicReference observed = new AtomicReference<>(); + + CreateResponse response = + adapter(observed) + .handle( + new CreateRequest("a title"), + context(new GrpcCancellationToken(), Duration.ofSeconds(1))); + + assertThat(response.documentId()).isEqualTo("document-42"); + assertThat(observed.get().identity().actorId()).isEqualTo("actor-1"); + } + + @Test + @DisplayName("the use case is handed a request context, never a transport type") + void theUseCaseSeesOnlyTheRequestContext() { + assertThat(GrpcApplicationInvocation.class.getMethods()) + .filteredOn(method -> "invoke".equals(method.getName())) + .singleElement() + .satisfies( + method -> + assertThat(method.getParameterTypes()[1]).isEqualTo(GrpcRequestContext.class)); + } + + @Test + @DisplayName("a cancelled or expired call is refused before the use case runs") + void aDeadCallIsRefusedBeforeTheUseCase() { + AtomicReference observed = new AtomicReference<>(); + GrpcCancellationToken cancelled = new GrpcCancellationToken(); + cancelled.cancel("client-cancelled", Instant.parse("2026-08-30T10:00:00Z")); + + assertThatThrownBy( + () -> + adapter(observed) + .handle( + new CreateRequest("a title"), context(cancelled, Duration.ofSeconds(1)))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("CreateDocument"); + assertThat(observed.get()).isNull(); + + assertThatThrownBy( + () -> + adapter(observed) + .handle( + new CreateRequest("a title"), + context(new GrpcCancellationToken(), Duration.ZERO))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("the adapter never builds a status; failures go through the shared mapper") + void failuresGoThroughTheSharedMapper() { + GrpcServiceAdapter adapter = + adapter(new AtomicReference<>()); + GrpcFailureContext failure = + new GrpcFailureContext( + CREATE, + GrpcStatusCode.ABORTED, + GrpcFailureCategory.CONFLICT, + GrpcExecutionEvidence.notStarted(CREATE, RpcType.UNARY), + GrpcCompletionOutcome.REJECTED, + GrpcFailureContext.RetryDisposition.RETRYABLE, + 1, + Duration.ofMillis(3), + Optional.empty()); + + assertThat(adapter.mapFailure(new GrpcPlatformException(failure)).status().getCode()) + .isEqualTo(Status.Code.ABORTED); + assertThat(adapter.mapFailure(new IllegalStateException("driver detail")).status().getCode()) + .isEqualTo(Status.Code.INTERNAL); + assertThat( + adapter + .mapFailure(new IllegalStateException("driver detail")) + .status() + .getDescription()) + .doesNotContain("driver detail"); + } + + @Test + @DisplayName("a blocking adapter declares that it must leave the transport thread") + void aBlockingAdapterDeclaresItsExecutionModel() { + GrpcServiceAdapterDescriptor descriptor = GrpcServiceAdapterDescriptor.blockingUnary(CREATE); + + assertThat(descriptor.executionModel()) + .isEqualTo(GrpcServiceAdapterDescriptor.ExecutionModel.BLOCKING); + assertThat(descriptor.executionModel().requiresBoundedExecutor()).isTrue(); + assertThat(GrpcServiceAdapterDescriptor.ExecutionModel.ASYNCHRONOUS.requiresBoundedExecutor()) + .isFalse(); + } + + @Test + @DisplayName("an adapter missing a collaborator is refused at construction") + void anIncompleteAdapterIsRefused() { + assertThatThrownBy( + () -> + new GrpcServiceAdapter( + GrpcServiceAdapterDescriptor.blockingUnary(CREATE), + null, + (command, requestContext) -> new CreateResult("x"), + result -> new CreateResponse(result.documentId()), + errorMapper)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the adapter descriptor names a method the response mapper never sees") + void theResponseMapperIsAPureFunctionOfTheResult() { + assertThat(GrpcResponseMapper.class.getMethods()) + .filteredOn(method -> "toResponse".equals(method.getName())) + .singleElement() + .satisfies(method -> assertThat(method.getParameterCount()).isEqualTo(1)); + assertThat(List.of(GrpcResponseMapper.class.getInterfaces())).isEmpty(); + } +} diff --git a/src/grpc/grpc-spring-boot-starter/build.gradle b/src/grpc/grpc-spring-boot-starter/build.gradle new file mode 100644 index 00000000..d1575c73 --- /dev/null +++ b/src/grpc/grpc-spring-boot-starter/build.gradle @@ -0,0 +1,24 @@ +apply plugin: 'java-library' + +// The platform's composition boundary: typed properties, auto-configuration and the startup +// validator that refuses a deployment whose configuration contradicts a Stable invariant. +// +// It must never reach `:grpc-advanced:*`. That is not a comment — the registry's +// allowed_dependencies for this leaf omits every advanced id, `verifyCleanArchitectureDependencies` +// enforces it, and GrpcPlatformStartupValidatorTest asserts the same rule from the Java side. +dependencies { + api project(':grpc:grpc-core-api') + api project(':grpc:grpc-policy') + api project(':grpc:grpc-server') + api project(':grpc:grpc-client') + api project(':grpc:grpc-discovery') + api project(':grpc:grpc-admin') + api project(':grpc:grpc-observability') + + implementation project(':grpc:grpc-proto-contract') + implementation project(':grpc:grpc-codegen') + implementation project(':grpc:grpc-operation-ledger-jpa') + + implementation 'org.springframework.boot:spring-boot-autoconfigure' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' +} diff --git a/src/grpc/grpc-spring-boot-starter/gradle.lockfile b/src/grpc/grpc-spring-boot-starter/gradle.lockfile new file mode 100644 index 00000000..ee0cc9a4 --- /dev/null +++ b/src/grpc/grpc-spring-boot-starter/gradle.lockfile @@ -0,0 +1,112 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor +org.springframework.boot:spring-boot:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-commons:4.0.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-jpa:4.0.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jdbc:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-orm:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty= diff --git a/src/grpc/grpc-spring-boot-starter/src/main/java/dev/caskeleton/grpc/boot/GrpcPlatformAutoConfiguration.java b/src/grpc/grpc-spring-boot-starter/src/main/java/dev/caskeleton/grpc/boot/GrpcPlatformAutoConfiguration.java new file mode 100644 index 00000000..d04d0aed --- /dev/null +++ b/src/grpc/grpc-spring-boot-starter/src/main/java/dev/caskeleton/grpc/boot/GrpcPlatformAutoConfiguration.java @@ -0,0 +1,106 @@ +package dev.caskeleton.grpc.boot; + +import dev.caskeleton.grpc.admin.GrpcAdminExposurePolicy; +import dev.caskeleton.grpc.admin.GrpcDrainPolicy; +import dev.caskeleton.grpc.admin.GrpcHealthPolicy; +import dev.caskeleton.grpc.admin.GrpcReflectionPolicy; +import dev.caskeleton.grpc.admin.GrpcServiceHealthRegistry; +import dev.caskeleton.grpc.context.GrpcContextBinder; +import dev.caskeleton.grpc.context.GrpcContextPropagationPolicy; +import dev.caskeleton.grpc.error.GrpcErrorMapper; +import dev.caskeleton.grpc.server.GrpcAdmissionController; +import dev.caskeleton.grpc.server.GrpcExecutorProfile; +import dev.caskeleton.grpc.server.GrpcServerProfile; +import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Assembles the Stable platform when, and only when, a deployment asks for it. + * + *

Every collaborator is a plain object constructed here, matching how the rest of this + * repository composes its adapters. Nothing on this class reaches a {@code :grpc-advanced:} type, + * and nothing can: the registry does not permit the edge, so an import here would fail the build + * rather than this comment. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty( + prefix = "ca-skeleton.grpc.platform", + name = "enabled", + havingValue = "true", + matchIfMissing = false) +@EnableConfigurationProperties(GrpcPlatformProperties.class) +public class GrpcPlatformAutoConfiguration { + + /** The bounded executor profile the server runs application work on. */ + @Bean + @ConditionalOnMissingBean + public GrpcExecutorProfile grpcExecutorProfile(GrpcPlatformProperties properties) { + return GrpcExecutorProfile.boundedPool( + properties.getExecutorMaxPoolSize(), properties.getExecutorQueueCapacity()); + } + + /** The server transport profile. */ + @Bean + @ConditionalOnMissingBean + public GrpcServerProfile grpcServerProfile(GrpcExecutorProfile executorProfile) { + return GrpcServerProfile.stableNetty(executorProfile); + } + + /** Admission control, sized from the executor. */ + @Bean + @ConditionalOnMissingBean + public GrpcAdmissionController grpcAdmissionController(GrpcExecutorProfile executorProfile) { + return GrpcAdmissionController.forExecutor(executorProfile); + } + + /** The health registry, unready until something reports otherwise. */ + @Bean + @ConditionalOnMissingBean + public GrpcServiceHealthRegistry grpcServiceHealthRegistry() { + return new GrpcServiceHealthRegistry(GrpcHealthPolicy.standalone()); + } + + /** The reflection policy, defaulted from the environment. */ + @Bean + @ConditionalOnMissingBean + public GrpcReflectionPolicy grpcReflectionPolicy(GrpcPlatformProperties properties) { + return properties.getReflectionMode() == null + ? GrpcReflectionPolicy.defaultFor(properties.getEnvironment()) + : new GrpcReflectionPolicy( + properties.getReflectionMode(), + java.util.Set.of("admin"), + java.util.Set.of("ROLE_PLATFORM_ADMIN")); + } + + /** The admin exposure gates. */ + @Bean + @ConditionalOnMissingBean + public GrpcAdminExposurePolicy grpcAdminExposurePolicy() { + return GrpcAdminExposurePolicy.standard(); + } + + /** The shutdown budget. */ + @Bean + @ConditionalOnMissingBean + public GrpcDrainPolicy grpcDrainPolicy() { + return GrpcDrainPolicy.stable(); + } + + /** The context binder, failing closed on contextless work. */ + @Bean + @ConditionalOnMissingBean + public GrpcContextBinder grpcContextBinder() { + return new GrpcContextBinder(GrpcContextPropagationPolicy.stable()); + } + + /** The shared error mapper. Its execution ids are random and carry no derived content. */ + @Bean + @ConditionalOnMissingBean + public GrpcErrorMapper grpcErrorMapper() { + return new GrpcErrorMapper("grpc-platform", () -> UUID.randomUUID().toString()); + } +} diff --git a/src/grpc/grpc-spring-boot-starter/src/main/java/dev/caskeleton/grpc/boot/GrpcPlatformConfigurationException.java b/src/grpc/grpc-spring-boot-starter/src/main/java/dev/caskeleton/grpc/boot/GrpcPlatformConfigurationException.java new file mode 100644 index 00000000..260a6228 --- /dev/null +++ b/src/grpc/grpc-spring-boot-starter/src/main/java/dev/caskeleton/grpc/boot/GrpcPlatformConfigurationException.java @@ -0,0 +1,35 @@ +package dev.caskeleton.grpc.boot; + +import java.util.List; + +/** + * A configuration the platform refuses to start with. + * + *

Carries every violation rather than the first. A deployment whose configuration breaks four + * rules should learn all four at once; failing on the first turns a config review into four restart + * cycles, and each cycle is a deploy. + */ +public class GrpcPlatformConfigurationException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient List violations; + + /** Wraps the violations that stopped startup. */ + public GrpcPlatformConfigurationException(List violations) { + super(render(violations)); + this.violations = List.copyOf(violations); + } + + private static String render(List violations) { + if (violations == null || violations.isEmpty()) { + throw new IllegalArgumentException("a configuration failure names at least one violation"); + } + return "the gRPC platform refuses this configuration:\n " + String.join("\n ", violations); + } + + /** Every violation found. */ + public List violations() { + return violations; + } +} diff --git a/src/grpc/grpc-spring-boot-starter/src/main/java/dev/caskeleton/grpc/boot/GrpcPlatformProperties.java b/src/grpc/grpc-spring-boot-starter/src/main/java/dev/caskeleton/grpc/boot/GrpcPlatformProperties.java new file mode 100644 index 00000000..c16f5bee --- /dev/null +++ b/src/grpc/grpc-spring-boot-starter/src/main/java/dev/caskeleton/grpc/boot/GrpcPlatformProperties.java @@ -0,0 +1,139 @@ +package dev.caskeleton.grpc.boot; + +import dev.caskeleton.grpc.admin.GrpcReflectionMode; +import dev.caskeleton.grpc.security.GrpcTlsProfile; +import dev.caskeleton.grpc.server.GrpcServerTransport; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * The gRPC platform's typed configuration, bound from {@code ca-skeleton.grpc.platform.*}. + * + *

Off by default, like every other optional capability in this repository. A platform that + * starts because its jar is on the classpath is a platform that opens a port on a deployment nobody + * decided to give one to. + * + *

{@code ignoreUnknownFields = false} so a misspelled key fails startup rather than silently + * leaving a setting at its default — which is how a deployment ends up with production reflection + * enabled because somebody wrote {@code reflection-mode} under the wrong prefix. + */ +@ConfigurationProperties(prefix = "ca-skeleton.grpc.platform", ignoreUnknownFields = false) +public class GrpcPlatformProperties { + + /** Whether the platform is assembled at all. */ + private boolean enabled; + + /** Which environment this deployment is, which decides the TLS and reflection floors. */ + private GrpcTlsProfile.Environment environment = GrpcTlsProfile.Environment.LOCAL; + + /** The server transport. Only a production transport is accepted outside tests. */ + private GrpcServerTransport transport = GrpcServerTransport.NETTY_SHADED; + + /** Reflection exposure. Defaults to the environment's floor when left unset. */ + private GrpcReflectionMode reflectionMode; + + /** Whether TLS is configured for the server and the client channels. */ + private boolean tlsEnabled = true; + + /** + * Explicit acknowledgement that certificates are not verified. Refused outside LOCAL and TEST. + */ + private boolean trustAllCertificates; + + /** The bounded executor's queue capacity. Zero means unbounded, which is refused. */ + private int executorQueueCapacity = 256; + + /** The bounded executor's maximum size. */ + private int executorMaxPoolSize = 32; + + /** The default deadline applied to a Stable unary method that declares none. */ + private Duration defaultUnaryDeadline = Duration.ofSeconds(2); + + /** Whether the operation ledger is available, which gates keyed idempotency. */ + private boolean operationLedgerEnabled; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public GrpcTlsProfile.Environment getEnvironment() { + return environment; + } + + public void setEnvironment(GrpcTlsProfile.Environment environment) { + this.environment = environment; + } + + public GrpcServerTransport getTransport() { + return transport; + } + + public void setTransport(GrpcServerTransport transport) { + this.transport = transport; + } + + public GrpcReflectionMode getReflectionMode() { + return reflectionMode; + } + + public void setReflectionMode(GrpcReflectionMode reflectionMode) { + this.reflectionMode = reflectionMode; + } + + public boolean isTlsEnabled() { + return tlsEnabled; + } + + public void setTlsEnabled(boolean tlsEnabled) { + this.tlsEnabled = tlsEnabled; + } + + public boolean isTrustAllCertificates() { + return trustAllCertificates; + } + + public void setTrustAllCertificates(boolean trustAllCertificates) { + this.trustAllCertificates = trustAllCertificates; + } + + public int getExecutorQueueCapacity() { + return executorQueueCapacity; + } + + public void setExecutorQueueCapacity(int executorQueueCapacity) { + this.executorQueueCapacity = executorQueueCapacity; + } + + public int getExecutorMaxPoolSize() { + return executorMaxPoolSize; + } + + public void setExecutorMaxPoolSize(int executorMaxPoolSize) { + this.executorMaxPoolSize = executorMaxPoolSize; + } + + public Duration getDefaultUnaryDeadline() { + return defaultUnaryDeadline; + } + + public void setDefaultUnaryDeadline(Duration defaultUnaryDeadline) { + this.defaultUnaryDeadline = defaultUnaryDeadline; + } + + public boolean isOperationLedgerEnabled() { + return operationLedgerEnabled; + } + + public void setOperationLedgerEnabled(boolean operationLedgerEnabled) { + this.operationLedgerEnabled = operationLedgerEnabled; + } + + /** The reflection mode this deployment runs, defaulting to the environment's floor. */ + public GrpcReflectionMode effectiveReflectionMode() { + return reflectionMode == null ? GrpcReflectionMode.defaultFor(environment) : reflectionMode; + } +} diff --git a/src/grpc/grpc-spring-boot-starter/src/main/java/dev/caskeleton/grpc/boot/GrpcPlatformStartupValidator.java b/src/grpc/grpc-spring-boot-starter/src/main/java/dev/caskeleton/grpc/boot/GrpcPlatformStartupValidator.java new file mode 100644 index 00000000..51de8d00 --- /dev/null +++ b/src/grpc/grpc-spring-boot-starter/src/main/java/dev/caskeleton/grpc/boot/GrpcPlatformStartupValidator.java @@ -0,0 +1,188 @@ +package dev.caskeleton.grpc.boot; + +import dev.caskeleton.grpc.admin.GrpcReflectionMode; +import dev.caskeleton.grpc.client.GrpcNamedChannelProfile; +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.GrpcStableBuildInvariant; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.discovery.GrpcDiscoveryPolicyValidator; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import dev.caskeleton.grpc.policy.GrpcMethodPolicyCatalog; +import dev.caskeleton.grpc.policy.RpcIdempotencyProfile; +import dev.caskeleton.grpc.resilience.GrpcRetryOwner; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Refuses to start on a configuration that would be wrong in a way nobody would notice. + * + *

Every rule here is a mistake whose runtime symptom is either silence or a misattributed + * failure: a unary method with no deadline hangs until the client's, an unbounded executor turns + * overload into unbounded latency, trust-all in production reports TLS while providing none, + * reflection in production publishes the schema, and a keyed method without a ledger accepts + * idempotency keys it cannot honour. None of them fails a smoke test. + * + *

Fails once with every violation, so a deployment learns the whole list in one restart. + */ +public final class GrpcPlatformStartupValidator { + + private GrpcPlatformStartupValidator() {} + + /** + * Every reason this configuration is refused. + * + * @param stableModuleDependencies the module ids the Stable starter resolves, so an Advanced leak + * is caught at startup as well as by the registry gate at build time + * @return an empty list when the configuration is safe to start + */ + public static List violations( + GrpcPlatformProperties properties, + GrpcMethodPolicyCatalog catalog, + List channelProfiles, + Set stableModuleDependencies) { + if (properties == null + || catalog == null + || channelProfiles == null + || stableModuleDependencies == null) { + throw new IllegalArgumentException("startup validation needs every input"); + } + List violations = new ArrayList<>(); + + validateTransportAndSecurity(properties, violations); + validateExecutor(properties, violations); + validateMethods(properties, catalog, violations); + validateChannels(channelProfiles, violations); + validateAdvancedIsolation(stableModuleDependencies, violations); + return List.copyOf(violations); + } + + /** + * Fails when the configuration is refused. + * + * @throws GrpcPlatformConfigurationException listing every violation + */ + public static void requireValid( + GrpcPlatformProperties properties, + GrpcMethodPolicyCatalog catalog, + List channelProfiles, + Set stableModuleDependencies) { + List violations = + violations(properties, catalog, channelProfiles, stableModuleDependencies); + if (!violations.isEmpty()) { + throw new GrpcPlatformConfigurationException(violations); + } + } + + private static void validateTransportAndSecurity( + GrpcPlatformProperties properties, List violations) { + if (!properties.getTransport().production()) { + violations.add( + "transport " + + properties.getTransport() + + " may not serve production traffic; in-process evidence is not network evidence"); + } + boolean deployed = properties.getEnvironment().tlsRequired(); + if (deployed && !properties.isTlsEnabled()) { + violations.add("TLS is required in " + properties.getEnvironment() + " and is disabled"); + } + if (deployed && properties.isTrustAllCertificates()) { + violations.add( + "trust-all is enabled in " + + properties.getEnvironment() + + "; accepting any certificate reports transport security while providing none"); + } + if (deployed && properties.effectiveReflectionMode() == GrpcReflectionMode.ENABLED) { + violations.add( + "reflection is fully enabled in " + + properties.getEnvironment() + + "; the complete schema would be published to anyone who can open a connection"); + } + } + + private static void validateExecutor(GrpcPlatformProperties properties, List violations) { + if (properties.getExecutorQueueCapacity() < 1) { + violations.add( + "the server executor queue is unbounded; overload becomes unbounded latency, and the work " + + "that eventually runs is work nobody is waiting for"); + } + if (properties.getExecutorMaxPoolSize() < 1) { + violations.add("the server executor pool size must be positive"); + } + } + + private static void validateMethods( + GrpcPlatformProperties properties, GrpcMethodPolicyCatalog catalog, List violations) { + for (GrpcMethodName method : sorted(catalog.methods())) { + GrpcMethodPolicy policy = catalog.require(method); + if (policy.rpcType() == RpcType.UNARY && policy.deadline().usable().isZero()) { + violations.add( + "Stable unary method '" + + method.canonical() + + "' has no usable deadline; the call hangs until the client's own deadline fires"); + } + if (policy.explicitRetryEnabled() && !policy.idempotency().explicitRetryAllowed()) { + violations.add( + "method '" + + method.canonical() + + "' is " + + policy.idempotency() + + " and carries an explicit retry policy"); + } + if (policy.idempotency() == RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED + && !properties.isOperationLedgerEnabled()) { + violations.add( + "method '" + + method.canonical() + + "' requires an idempotency key but no operation ledger is enabled; the key would " + + "be accepted and never honoured"); + } + if (!policy.rpcType().stable()) { + violations.add( + "method '" + + method.canonical() + + "' is " + + policy.rpcType() + + ", which is an Advanced capability rather than part of the Stable scope"); + } + } + } + + private static void validateChannels( + List channelProfiles, List violations) { + Set ownersRetryingInProcess = new LinkedHashSet<>(); + for (GrpcNamedChannelProfile profile : channelProfiles) { + try { + GrpcDiscoveryPolicyValidator.requireStableScheme(profile.target().getScheme()); + } catch (IllegalArgumentException unsupported) { + violations.add("channel '" + profile.name().value() + "': " + unsupported.getMessage()); + } + if (profile.retryOwner().explicitRetryInProcess()) { + ownersRetryingInProcess.add(profile.retryOwner()); + } + } + if (ownersRetryingInProcess.size() > 1) { + violations.add( + "two retry owners retry in-process across the configured channels " + + ownersRetryingInProcess + + "; retries at two layers multiply the load on a dependency that is already failing"); + } + } + + private static void validateAdvancedIsolation( + Set stableModuleDependencies, List violations) { + try { + GrpcStableBuildInvariant.requireNoAdvancedDependency( + "grpc-spring-boot-starter", stableModuleDependencies); + } catch (IllegalStateException leaked) { + violations.add(leaked.getMessage()); + } + } + + private static List sorted(Set methods) { + return methods.stream() + .sorted(java.util.Comparator.comparing(GrpcMethodName::canonical)) + .toList(); + } +} diff --git a/src/grpc/grpc-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/grpc/grpc-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..d9da7d82 --- /dev/null +++ b/src/grpc/grpc-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +dev.caskeleton.grpc.boot.GrpcPlatformAutoConfiguration diff --git a/src/grpc/grpc-spring-boot-starter/src/test/java/dev/caskeleton/grpc/boot/GrpcPlatformStartupValidatorTest.java b/src/grpc/grpc-spring-boot-starter/src/test/java/dev/caskeleton/grpc/boot/GrpcPlatformStartupValidatorTest.java new file mode 100644 index 00000000..25911e09 --- /dev/null +++ b/src/grpc/grpc-spring-boot-starter/src/test/java/dev/caskeleton/grpc/boot/GrpcPlatformStartupValidatorTest.java @@ -0,0 +1,267 @@ +package dev.caskeleton.grpc.boot; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.admin.GrpcReflectionMode; +import dev.caskeleton.grpc.client.GrpcNamedChannelProfile; +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import dev.caskeleton.grpc.policy.GrpcMethodPolicyCatalog; +import dev.caskeleton.grpc.policy.RpcIdempotencyProfile; +import dev.caskeleton.grpc.policy.WaitForReadyPolicy; +import dev.caskeleton.grpc.resilience.GrpcRetryOwner; +import dev.caskeleton.grpc.security.GrpcTlsProfile; +import dev.caskeleton.grpc.server.GrpcServerTransport; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcPlatformStartupValidatorTest { + + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + private static final GrpcMethodName CREATE = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument"); + private static final GrpcTlsProfile PROD_TLS = + GrpcTlsProfile.serverAuthenticated(GrpcTlsProfile.Environment.PROD, "trust-bundle"); + + private static final Set STABLE_DEPENDENCIES = + Set.of("grpc-core-api", "grpc-policy", "grpc-server", "grpc-client"); + + private static GrpcPlatformProperties productionProperties() { + GrpcPlatformProperties properties = new GrpcPlatformProperties(); + properties.setEnabled(true); + properties.setEnvironment(GrpcTlsProfile.Environment.PROD); + properties.setTransport(GrpcServerTransport.NETTY_SHADED); + properties.setTlsEnabled(true); + properties.setTrustAllCertificates(false); + properties.setExecutorQueueCapacity(256); + properties.setExecutorMaxPoolSize(32); + properties.setOperationLedgerEnabled(true); + return properties; + } + + private static GrpcMethodPolicyCatalog healthyCatalog() { + return GrpcMethodPolicyCatalog.builder() + .register( + GrpcMethodPolicy.readOnlyUnary(GET, GrpcDeadlineProfile.of(Duration.ofSeconds(2)))) + .build(); + } + + private static List oneChannel() { + return List.of( + GrpcNamedChannelProfile.virtualIp( + "documents-read", + URI.create("dns:///documents:9090"), + PROD_TLS, + GrpcRetryOwner.GRPC_PLATFORM)); + } + + @Test + @DisplayName("a coherent production configuration starts") + void aCoherentConfigurationStarts() { + assertThat( + GrpcPlatformStartupValidator.violations( + productionProperties(), healthyCatalog(), oneChannel(), STABLE_DEPENDENCIES)) + .isEmpty(); + GrpcPlatformStartupValidator.requireValid( + productionProperties(), healthyCatalog(), oneChannel(), STABLE_DEPENDENCIES); + } + + @Test + @DisplayName("the platform is off unless a deployment turns it on") + void thePlatformIsOffByDefault() { + assertThat(new GrpcPlatformProperties().isEnabled()).isFalse(); + } + + @Test + @DisplayName("an in-process transport may not serve production") + void inProcessIsRefusedInProduction() { + GrpcPlatformProperties properties = productionProperties(); + properties.setTransport(GrpcServerTransport.IN_PROCESS); + + assertThat( + GrpcPlatformStartupValidator.violations( + properties, healthyCatalog(), oneChannel(), STABLE_DEPENDENCIES)) + .anySatisfy(violation -> assertThat(violation).contains("not network evidence")); + } + + @Test + @DisplayName("insecure production TLS is refused") + void insecureProductionTlsIsRefused() { + GrpcPlatformProperties noTls = productionProperties(); + noTls.setTlsEnabled(false); + GrpcPlatformProperties trustAll = productionProperties(); + trustAll.setTrustAllCertificates(true); + + assertThat( + GrpcPlatformStartupValidator.violations( + noTls, healthyCatalog(), oneChannel(), STABLE_DEPENDENCIES)) + .anySatisfy(violation -> assertThat(violation).contains("TLS is required")); + assertThat( + GrpcPlatformStartupValidator.violations( + trustAll, healthyCatalog(), oneChannel(), STABLE_DEPENDENCIES)) + .anySatisfy(violation -> assertThat(violation).contains("providing none")); + } + + @Test + @DisplayName("production reflection is refused") + void productionReflectionIsRefused() { + GrpcPlatformProperties properties = productionProperties(); + properties.setReflectionMode(GrpcReflectionMode.ENABLED); + + assertThat( + GrpcPlatformStartupValidator.violations( + properties, healthyCatalog(), oneChannel(), STABLE_DEPENDENCIES)) + .anySatisfy(violation -> assertThat(violation).contains("published to anyone")); + assertThat(productionProperties().effectiveReflectionMode()) + .isEqualTo(GrpcReflectionMode.DISABLED); + } + + @Test + @DisplayName("an unbounded server executor is refused") + void anUnboundedExecutorIsRefused() { + GrpcPlatformProperties properties = productionProperties(); + properties.setExecutorQueueCapacity(0); + + assertThat( + GrpcPlatformStartupValidator.violations( + properties, healthyCatalog(), oneChannel(), STABLE_DEPENDENCIES)) + .anySatisfy(violation -> assertThat(violation).contains("unbounded latency")); + } + + @Test + @DisplayName("a keyed method with no operation ledger is refused") + void keyedMethodsNeedALedger() { + GrpcPlatformProperties properties = productionProperties(); + properties.setOperationLedgerEnabled(false); + GrpcMethodPolicyCatalog catalog = + GrpcMethodPolicyCatalog.builder() + .register( + new GrpcMethodPolicy( + CREATE, + RpcType.UNARY, + RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED, + GrpcDeadlineProfile.of(Duration.ofSeconds(2)), + WaitForReadyPolicy.DISABLED, + false, + 1024, + 1024)) + .build(); + + assertThat( + GrpcPlatformStartupValidator.violations( + properties, catalog, oneChannel(), STABLE_DEPENDENCIES)) + .anySatisfy(violation -> assertThat(violation).contains("never honoured")); + } + + @Test + @DisplayName("a client-streaming method is refused as an Advanced capability") + void clientStreamingIsRefusedAsAdvanced() { + GrpcMethodPolicyCatalog catalog = + GrpcMethodPolicyCatalog.builder() + .register( + new GrpcMethodPolicy( + CREATE, + RpcType.CLIENT_STREAMING, + RpcIdempotencyProfile.STREAMING, + GrpcDeadlineProfile.of(Duration.ofSeconds(2)), + WaitForReadyPolicy.DISABLED, + false, + 1024, + 1024)) + .build(); + + assertThat( + GrpcPlatformStartupValidator.violations( + productionProperties(), catalog, oneChannel(), STABLE_DEPENDENCIES)) + .anySatisfy(violation -> assertThat(violation).contains("Advanced capability")); + } + + @Test + @DisplayName("two in-process retry owners across channels are refused") + void twoRetryOwnersAreRefused() { + List twoOwners = + List.of( + GrpcNamedChannelProfile.virtualIp( + "documents-read", + URI.create("dns:///documents:9090"), + PROD_TLS, + GrpcRetryOwner.GRPC_PLATFORM), + GrpcNamedChannelProfile.virtualIp( + "billing-write", + URI.create("dns:///billing:9090"), + PROD_TLS, + GrpcRetryOwner.APPLICATION)); + + assertThat( + GrpcPlatformStartupValidator.violations( + productionProperties(), healthyCatalog(), twoOwners, STABLE_DEPENDENCIES)) + .anySatisfy(violation -> assertThat(violation).contains("multiply the load")); + } + + @Test + @DisplayName("the Stable starter reaching an Advanced module is refused at startup too") + void advancedLeakIsRefusedAtStartup() { + Set leaked = Set.of("grpc-core-api", "grpc-policy", "grpc-advanced-streaming"); + + assertThat( + GrpcPlatformStartupValidator.violations( + productionProperties(), healthyCatalog(), oneChannel(), leaked)) + .anySatisfy(violation -> assertThat(violation).contains("grpc-advanced-streaming")); + } + + @Test + @DisplayName("a refusal names every violation at once") + void aRefusalNamesEveryViolation() { + GrpcPlatformProperties broken = productionProperties(); + broken.setTlsEnabled(false); + broken.setTrustAllCertificates(true); + broken.setExecutorQueueCapacity(0); + + assertThatThrownBy( + () -> + GrpcPlatformStartupValidator.requireValid( + broken, healthyCatalog(), oneChannel(), STABLE_DEPENDENCIES)) + .isInstanceOf(GrpcPlatformConfigurationException.class) + .satisfies( + failure -> + assertThat(((GrpcPlatformConfigurationException) failure).violations()) + .hasSizeGreaterThanOrEqualTo(3)); + } + + @Test + @DisplayName("the auto-configuration is registered and reaches no Advanced module") + void theAutoConfigurationIsRegisteredAndStable() { + String imports = + read( + Path.of( + "src/main/resources/META-INF/spring/" + + "org.springframework.boot.autoconfigure.AutoConfiguration.imports")); + + assertThat(imports.strip()).isEqualTo("dev.caskeleton.grpc.boot.GrpcPlatformAutoConfiguration"); + // The dependency declaration, not the word: the build file's own comment says it must never + // reach an advanced module, and matching on the prose would fail on the sentence stating the + // rule. + assertThat(read(Path.of("build.gradle"))).doesNotContain("project(':grpc-advanced"); + } + + private static String read(Path path) { + try { + return Files.readString(path, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/grpc/grpc-testkit/build.gradle b/src/grpc/grpc-testkit/build.gradle new file mode 100644 index 00000000..0852528d --- /dev/null +++ b/src/grpc/grpc-testkit/build.gradle @@ -0,0 +1,65 @@ +apply plugin: 'java-library' + +// Certification. The Stable plan splits this across four modules (core / in-process / netty / +// fault); this repository already expresses "these two runs are not the same kind of evidence" with +// strict test lanes rather than with module boundaries, so the four become four lanes over one +// leaf (adaptation design §2). A lane that discovers nothing fails, and none of them can serve an +// up-to-date result — which is the property the split was protecting. +dependencyManagement { + imports { + mavenBom "io.grpc:grpc-bom:${grpcVersion}" + } +} + +strictTestLanes { + lane('grpcInProcessContractTest') { + tag = 'grpc-inprocess' + description = 'In-process contract lane: adapter, interceptor order, status, idempotency ' + + 'replay. Never HTTP/2, TLS or transport-limit evidence.' + } + lane('grpcNettyContractTest') { + tag = 'grpc-netty' + description = 'Real Netty lane: HTTP/2 on an ephemeral socket, TLS/mTLS, metadata and ' + + 'message hard limits, GOAWAY, keepalive and drain.' + } + lane('grpcFaultTest') { + tag = 'grpc-fault' + description = 'Fault-injection lane: connection loss at each evidence boundary, and the ' + + 'classifier that refuses to infer NOT_SENT from an unobserved state.' + } + lane('grpcPerformanceTest') { + tag = 'grpc-performance' + description = 'Performance lane: unary latency percentiles, channel stream saturation, ' + + 'executor saturation and drain budget against a recorded baseline.' + } +} + +// The performance lane is excluded from the default `test` task. It measures a running server +// under load, and a measurement in the release gate is a flaky test on a shared CI runner; it runs +// when somebody asks for it, by name. +tasks.named('test') { + useJUnitPlatform { + excludeTags 'grpc-performance' + } +} + +dependencies { + api project(':grpc:grpc-core-api') + api project(':grpc:grpc-policy') + api project(':grpc:grpc-server') + api project(':grpc:grpc-client') + api project(':grpc:grpc-discovery') + api project(':grpc:grpc-admin') + api project(':grpc:grpc-observability') + api project(':grpc:grpc-proto-contract') + api project(':grpc:grpc-codegen') + api project(':grpc:grpc-operation-ledger-jpa') + + api "io.grpc:grpc-api:${grpcVersion}" + api "io.grpc:grpc-stub:${grpcVersion}" + + // The transports the lanes stand on. In-process is fast contract evidence; Netty is the only + // transport this platform certifies, and the lane separation is what keeps them distinguishable. + implementation "io.grpc:grpc-inprocess:${grpcVersion}" + implementation "io.grpc:grpc-netty-shaded:${grpcVersion}" +} diff --git a/src/grpc/grpc-testkit/gradle.lockfile b/src/grpc/grpc-testkit/gradle.lockfile new file mode 100644 index 00000000..e5a66488 --- /dev/null +++ b/src/grpc/grpc-testkit/gradle.lockfile @@ -0,0 +1,117 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.android:annotations:4.1.1.4=runtimeClasspath,testRuntimeClasspath +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=runtimeClasspath,spotbugs,testRuntimeClasspath +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=runtimeClasspath,spotbugs,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-context:1.68.1=runtimeClasspath,testRuntimeClasspath +io.grpc:grpc-core:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-inprocess:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-netty-shaded:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-util:1.68.1=runtimeClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.perfmark:perfmark-api:0.27.0=runtimeClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.codehaus.mojo:animal-sniffer-annotations:1.24=runtimeClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.20.0=mockitoAgent +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.data:spring-data-commons:4.0.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-jpa:4.0.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jdbc:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-orm:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +empty= diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/performance/GrpcPerformanceBudget.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/performance/GrpcPerformanceBudget.java new file mode 100644 index 00000000..124138b1 --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/performance/GrpcPerformanceBudget.java @@ -0,0 +1,78 @@ +package dev.caskeleton.grpc.performance; + +import java.time.Duration; + +/** + * The numbers a release is allowed to be. + * + *

Absolute bounds plus a regression allowance against a recorded baseline, because either alone + * is inadequate. An absolute p99 that a fast machine passes and a CI runner does not is a flaky + * gate; a pure regression check accepts any absolute latency as long as it degrades slowly, which + * is how a service ends up at a p99 nobody would have approved in one step. + */ +public record GrpcPerformanceBudget( + Duration maxUnaryP50, + Duration maxUnaryP95, + Duration maxUnaryP99, + double maxErrorRate, + int maxConcurrentStreams, + Duration maxClientQueueTime, + Duration maxDrainDuration, + double allowedRegressionRatio) { + + /** Refuses an incoherent or unbounded budget. */ + public GrpcPerformanceBudget { + requireOrdered(maxUnaryP50, maxUnaryP95, "p50", "p95"); + requireOrdered(maxUnaryP95, maxUnaryP99, "p95", "p99"); + if (maxErrorRate < 0.0d || maxErrorRate > 1.0d) { + throw new IllegalArgumentException("an error rate bound is a fraction between 0 and 1"); + } + if (maxConcurrentStreams < 1) { + throw new IllegalArgumentException("a stream bound must be positive"); + } + if (maxClientQueueTime == null || maxClientQueueTime.isNegative()) { + throw new IllegalArgumentException("a queue time bound must be present and non-negative"); + } + if (maxDrainDuration == null || maxDrainDuration.isZero() || maxDrainDuration.isNegative()) { + throw new IllegalArgumentException("a drain bound must be positive"); + } + if (allowedRegressionRatio < 1.0d) { + throw new IllegalArgumentException( + "a regression allowance below 1.0 would fail a release that got faster"); + } + if (allowedRegressionRatio > 2.0d) { + throw new IllegalArgumentException( + "an allowance above 2.0 permits a doubling between releases, which is not a gate"); + } + } + + private static void requireOrdered( + Duration lower, Duration upper, String lowerName, String upperName) { + if (lower == null || upper == null) { + throw new IllegalArgumentException("percentile bounds must be present"); + } + if (lower.isNegative() || upper.isNegative()) { + throw new IllegalArgumentException("percentile bounds must not be negative"); + } + if (lower.compareTo(upper) > 0) { + throw new IllegalArgumentException( + lowerName + + " bound is above the " + + upperName + + " bound, which cannot happen in a sample"); + } + } + + /** A starting budget for a Stable unary service. */ + public static GrpcPerformanceBudget stable() { + return new GrpcPerformanceBudget( + Duration.ofMillis(20), + Duration.ofMillis(80), + Duration.ofMillis(200), + 0.001d, + 1000, + Duration.ofMillis(50), + Duration.ofSeconds(25), + 1.2d); + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/performance/GrpcPerformanceGate.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/performance/GrpcPerformanceGate.java new file mode 100644 index 00000000..eb4e009e --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/performance/GrpcPerformanceGate.java @@ -0,0 +1,95 @@ +package dev.caskeleton.grpc.performance; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Compares a run against its budget and against the last recorded baseline. + * + *

Both comparisons, in that order. A run that passes the absolute bound but is thirty percent + * slower than the last release is a regression that the absolute bound will keep hiding until the + * release that finally crosses it — at which point the change responsible is several releases back. + */ +public final class GrpcPerformanceGate { + + private final GrpcPerformanceBudget budget; + + /** Binds a gate to its budget. */ + public GrpcPerformanceGate(GrpcPerformanceBudget budget) { + if (budget == null) { + throw new IllegalArgumentException("a performance gate needs a budget"); + } + this.budget = budget; + } + + /** + * Every way {@code result} fails. + * + * @param baseline the previous release's measurements, when there is one + * @return an empty list when the run is within budget and within the regression allowance + */ + public List violations( + GrpcPerformanceResult result, Optional baseline) { + if (result == null || baseline == null) { + throw new IllegalArgumentException("a gate needs a result and the baseline Optional"); + } + List violations = new ArrayList<>(); + + checkBound(violations, "p50", result.unaryP50(), budget.maxUnaryP50()); + checkBound(violations, "p95", result.unaryP95(), budget.maxUnaryP95()); + checkBound(violations, "p99", result.unaryP99(), budget.maxUnaryP99()); + checkBound( + violations, "client queue time", result.peakClientQueueTime(), budget.maxClientQueueTime()); + checkBound(violations, "drain duration", result.drainDuration(), budget.maxDrainDuration()); + if (result.errorRate() > budget.maxErrorRate()) { + violations.add( + "error rate " + result.errorRate() + " exceeds the budget " + budget.maxErrorRate()); + } + if (result.peakConcurrentStreams() > budget.maxConcurrentStreams()) { + violations.add( + "peak concurrent streams " + + result.peakConcurrentStreams() + + " exceeds the budget " + + budget.maxConcurrentStreams()); + } + + baseline.ifPresent( + previous -> { + checkRegression(violations, "p95", result.unaryP95(), previous.unaryP95()); + checkRegression(violations, "p99", result.unaryP99(), previous.unaryP99()); + }); + return List.copyOf(violations); + } + + private static void checkBound( + List violations, String name, Duration observed, Duration bound) { + if (observed.compareTo(bound) > 0) { + violations.add( + name + " " + observed.toMillis() + "ms exceeds the budget " + bound.toMillis() + "ms"); + } + } + + private void checkRegression( + List violations, String name, Duration observed, Duration baseline) { + if (baseline.isZero()) { + return; + } + double ratio = (double) observed.toNanos() / (double) baseline.toNanos(); + if (ratio > budget.allowedRegressionRatio()) { + violations.add( + name + + " regressed by " + + Math.round((ratio - 1.0d) * 100.0d) + + "% against the baseline, above the allowed " + + Math.round((budget.allowedRegressionRatio() - 1.0d) * 100.0d) + + "%"); + } + } + + /** The budget in force. */ + public GrpcPerformanceBudget budget() { + return budget; + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/performance/GrpcPerformanceResult.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/performance/GrpcPerformanceResult.java new file mode 100644 index 00000000..0b119c3e --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/performance/GrpcPerformanceResult.java @@ -0,0 +1,66 @@ +package dev.caskeleton.grpc.performance; + +import dev.caskeleton.grpc.testkit.GrpcEvidenceGrade; +import java.time.Duration; + +/** + * One performance run's measurements. + * + *

Saturation is recorded in three places rather than one, because they are three different + * incidents that produce the same latency graph. A blocking executor at its bound, a database pool + * at its bound and an HTTP/2 stream limit at its bound all look like the server got slower, and the + * fix for each is somewhere else. + */ +public record GrpcPerformanceResult( + GrpcEvidenceGrade grade, + Duration unaryP50, + Duration unaryP95, + Duration unaryP99, + double errorRate, + int peakConcurrentStreams, + Duration peakClientQueueTime, + int executorSaturationEvents, + int channelSaturationEvents, + long flowControlStalls, + Duration drainDuration) { + + /** Requires a performance grade and coherent measurements. */ + public GrpcPerformanceResult { + if (grade != GrpcEvidenceGrade.PERFORMANCE) { + throw new IllegalArgumentException( + "a performance result must be " + + GrpcEvidenceGrade.PERFORMANCE + + " evidence; " + + grade + + " did not measure a running server under load"); + } + if (unaryP50 == null + || unaryP95 == null + || unaryP99 == null + || peakClientQueueTime == null + || drainDuration == null) { + throw new IllegalArgumentException("every performance measurement must be present"); + } + if (errorRate < 0.0d || errorRate > 1.0d) { + throw new IllegalArgumentException("an error rate is a fraction between 0 and 1"); + } + if (peakConcurrentStreams < 0 + || executorSaturationEvents < 0 + || channelSaturationEvents < 0 + || flowControlStalls < 0) { + throw new IllegalArgumentException("saturation counters must not be negative"); + } + } + + /** Which bottleneck this run hit, if any. */ + public String dominantBottleneck() { + if (executorSaturationEvents > channelSaturationEvents + && executorSaturationEvents > flowControlStalls) { + return "executor"; + } + if (channelSaturationEvents > flowControlStalls) { + return "channel"; + } + return flowControlStalls > 0 ? "flow-control" : "none"; + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/release/GrpcCompatibilityMatrix.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/release/GrpcCompatibilityMatrix.java new file mode 100644 index 00000000..43853025 --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/release/GrpcCompatibilityMatrix.java @@ -0,0 +1,93 @@ +package dev.caskeleton.grpc.release; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Which combinations a release is certified against, and which are only watched. + * + *

The distinction is what keeps a support claim honest. "Works with Spring Boot" is not a + * statement anyone can act on; "certified on the managed Boot platform, compatibility-checked + * against an upstream gRPC override, and not run at all on Edition 2024" is. + */ +public record GrpcCompatibilityMatrix(Map lanes) { + + /** How much a combination has actually been exercised. */ + public enum Lane { + /** Run on every release. A failure blocks. */ + CERTIFIED(true), + /** Run, but a failure is reported rather than blocking. */ + COMPATIBILITY(false), + /** Recorded and not run. */ + WATCH(false); + + private final boolean blocking; + + Lane(boolean blocking) { + this.blocking = blocking; + } + + /** Whether a failure in this lane blocks a release. */ + public boolean blocking() { + return blocking; + } + } + + /** Copies the lane map. */ + public GrpcCompatibilityMatrix { + if (lanes == null || lanes.isEmpty()) { + throw new IllegalArgumentException("a compatibility matrix names at least one lane"); + } + lanes = Map.copyOf(lanes); + if (lanes.values().stream().noneMatch(Lane::blocking)) { + throw new IllegalArgumentException( + "a matrix with no certified lane certifies nothing; every combination is optional"); + } + } + + /** This repository's matrix. */ + public static GrpcCompatibilityMatrix caSkeleton() { + return new GrpcCompatibilityMatrix( + Map.of( + "boot-managed-platform", Lane.CERTIFIED, + "proto3-explicit-optional", Lane.CERTIFIED, + "netty-shaded", Lane.CERTIFIED, + "netty-unshaded", Lane.COMPATIBILITY, + "upstream-grpc-java-override", Lane.COMPATIBILITY, + "protobuf-edition-2024", Lane.WATCH, + "protobuf-edition-2026", Lane.WATCH)); + } + + /** The lanes whose failure blocks a release. */ + public List blockingLanes() { + return lanes.entrySet().stream() + .filter(entry -> entry.getValue().blocking()) + .map(Map.Entry::getKey) + .sorted() + .toList(); + } + + /** + * Every blocking lane that has no result. + * + * @return an empty list when every certified lane ran + */ + public List missingResults(Map laneResults) { + if (laneResults == null) { + throw new IllegalArgumentException("a lane result map is required"); + } + List missing = new ArrayList<>(); + blockingLanes() + .forEach( + lane -> { + Boolean passed = laneResults.get(lane); + if (passed == null) { + missing.add("certified lane '" + lane + "' produced no result"); + } else if (!passed) { + missing.add("certified lane '" + lane + "' failed"); + } + }); + return List.copyOf(missing); + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/release/GrpcReleaseDecision.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/release/GrpcReleaseDecision.java new file mode 100644 index 00000000..903ec415 --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/release/GrpcReleaseDecision.java @@ -0,0 +1,42 @@ +package dev.caskeleton.grpc.release; + +import java.util.List; + +/** + * Whether a Stable release may go out. + * + *

Blockers are a list rather than a boolean because a release conversation is about what is + * missing. "The gate failed" starts a search; "the fault lane did not run and the runbook is + * absent" starts the work. + */ +public record GrpcReleaseDecision(boolean approved, List blockers) { + + /** Requires blockers exactly when refused. */ + public GrpcReleaseDecision { + if (blockers == null) { + throw new IllegalArgumentException("a release decision lists its blockers, even when empty"); + } + blockers = List.copyOf(blockers); + if (approved && !blockers.isEmpty()) { + throw new IllegalArgumentException("an approved release has no blockers"); + } + if (!approved && blockers.isEmpty()) { + throw new IllegalArgumentException("a refused release says why"); + } + } + + /** + * An approved release. + * + *

Named {@code approve} rather than {@code approved}, which the record already uses for its + * accessor. + */ + public static GrpcReleaseDecision approve() { + return new GrpcReleaseDecision(true, List.of()); + } + + /** A refused release. */ + public static GrpcReleaseDecision refuse(List blockers) { + return new GrpcReleaseDecision(false, blockers); + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/release/GrpcReleaseEvidence.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/release/GrpcReleaseEvidence.java new file mode 100644 index 00000000..7161c12a --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/release/GrpcReleaseEvidence.java @@ -0,0 +1,63 @@ +package dev.caskeleton.grpc.release; + +import dev.caskeleton.grpc.testkit.GrpcEvidenceGrade; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * What a release actually has evidence for. + * + *

Graded, so a release cannot cite an in-process run as transport evidence. That substitution is + * the easiest one to make under time pressure and the hardest to spot afterwards: the suite name + * says "contract", the report says the platform is certified, and nothing in between records that + * no socket was opened. + */ +public record GrpcReleaseEvidence( + Set gradesRun, + Set certifiedCapabilities, + boolean runbookPresent, + boolean architectureDecisionRecordsPresent, + boolean supportMatrixPresent) { + + /** The grades a Stable release must have run. */ + private static final Set REQUIRED_GRADES = + EnumSet.of( + GrpcEvidenceGrade.CONTRACT, + GrpcEvidenceGrade.TRANSPORT, + GrpcEvidenceGrade.FAULT, + GrpcEvidenceGrade.PERFORMANCE); + + /** Copies both sets. */ + public GrpcReleaseEvidence { + if (gradesRun == null || certifiedCapabilities == null) { + throw new IllegalArgumentException("release evidence states what ran and what it certified"); + } + gradesRun = Set.copyOf(gradesRun); + certifiedCapabilities = Set.copyOf(certifiedCapabilities); + } + + /** The grades a Stable release requires. */ + public static Set requiredGrades() { + return Set.copyOf(REQUIRED_GRADES); + } + + /** The grades that were not run. */ + public Set missingGrades() { + Set missing = new LinkedHashSet<>(REQUIRED_GRADES); + missing.removeAll(gradesRun); + return Set.copyOf(missing); + } + + /** + * Whether {@code capability} is backed by a grade that can establish it. + * + *

The check the whole type exists for: a TLS claim needs a run that opened a socket. + */ + public boolean supports(String capability) { + if (!certifiedCapabilities.contains(capability)) { + return false; + } + return gradesRun.stream().anyMatch(grade -> grade.certifies().contains(capability)); + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/release/GrpcStableReleaseGate.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/release/GrpcStableReleaseGate.java new file mode 100644 index 00000000..862f8d13 --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/release/GrpcStableReleaseGate.java @@ -0,0 +1,96 @@ +package dev.caskeleton.grpc.release; + +import dev.caskeleton.grpc.codegen.GrpcSchemaArtifactPublisher; +import dev.caskeleton.grpc.testkit.GrpcEvidenceGrade; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * The last check before a Stable release: every lane ran, every grade is present, and the + * operational documents exist. + * + *

Documents are a blocker rather than a follow-up. A platform whose failure modes are {@code + * COMPLETION_UNKNOWN} and a stream that needs a full resync is a platform whose on-call has to be + * told what to do about them; shipping the behaviour and writing the runbook afterwards means the + * first person to meet it is the one who has to work it out at three in the morning. + */ +public final class GrpcStableReleaseGate { + + private final GrpcCompatibilityMatrix matrix; + + /** Binds the gate to its compatibility matrix. */ + public GrpcStableReleaseGate(GrpcCompatibilityMatrix matrix) { + if (matrix == null) { + throw new IllegalArgumentException("a release gate needs a compatibility matrix"); + } + this.matrix = matrix; + } + + /** + * The release decision. + * + * @param schemaDecision the schema publisher's verdict, which carries the consumer-compile result + */ + public GrpcReleaseDecision evaluate( + GrpcReleaseEvidence evidence, + Map laneResults, + GrpcSchemaArtifactPublisher.PublishDecision schemaDecision) { + if (evidence == null || laneResults == null || schemaDecision == null) { + throw new IllegalArgumentException( + "a release decision needs evidence, lanes and the schema verdict"); + } + List blockers = new ArrayList<>(matrix.missingResults(laneResults)); + + evidence.missingGrades().stream() + .sorted() + .forEach( + grade -> + blockers.add( + grade + + " evidence was not produced; without it the release cannot claim " + + grade.certifies().stream().sorted().toList())); + + if (!schemaDecision.allowed()) { + schemaDecision.blockers().forEach(blocker -> blockers.add("schema: " + blocker)); + } + if (!evidence.runbookPresent()) { + blockers.add( + "the operations runbook is missing; COMPLETION_UNKNOWN and FULL_RESYNC_REQUIRED are " + + "states an on-call has to be told what to do about"); + } + if (!evidence.architectureDecisionRecordsPresent()) { + blockers.add("the architecture decision records are missing"); + } + if (!evidence.supportMatrixPresent()) { + blockers.add("the support matrix is missing, so no combination has a stated support level"); + } + return blockers.isEmpty() + ? GrpcReleaseDecision.approve() + : GrpcReleaseDecision.refuse(blockers); + } + + /** + * Whether a capability may be advertised as supported. + * + * @throws IllegalStateException when the evidence behind it is of the wrong grade + */ + public static void requireCertified(GrpcReleaseEvidence evidence, String capability) { + if (evidence == null) { + throw new IllegalArgumentException("release evidence is required"); + } + if (!evidence.supports(capability)) { + throw new IllegalStateException( + "capability '" + + capability + + "' is not certified by the evidence this release produced (" + + evidence.gradesRun().stream().map(GrpcEvidenceGrade::name).sorted().toList() + + ")"); + } + } + + /** The matrix in force. */ + public GrpcCompatibilityMatrix matrix() { + return matrix; + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcEvidenceGrade.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcEvidenceGrade.java new file mode 100644 index 00000000..4ace17f0 --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcEvidenceGrade.java @@ -0,0 +1,80 @@ +package dev.caskeleton.grpc.testkit; + +import java.util.Set; + +/** + * What a lane's results are evidence about. + * + *

The Stable plan splits its testkit into four modules so that in-process results cannot be + * mistaken for network results. This repository expresses that with strict test lanes instead + * (adaptation design §2), and this enum is what keeps the distinction legible from inside the code: + * a claim about TLS backed by {@link #CONTRACT} evidence is refused, because in-process transport + * never negotiated one. + */ +public enum GrpcEvidenceGrade { + /** + * In-process. Proves adapter, interceptor, status and idempotency behaviour. Proves nothing about + * HTTP/2 framing, TLS, transport limits, keepalive or GOAWAY. + */ + CONTRACT, + /** Real Netty on a real socket. The only grade that certifies transport behaviour. */ + TRANSPORT, + /** Real network faults at each evidence boundary. */ + FAULT, + /** Latency, saturation and drain budget under load. */ + PERFORMANCE; + + /** + * What a result at this grade may be cited for. + * + *

Computed rather than held in a field: an enum with a collection field is a mutable enum as + * far as any static analysis can tell, and the alternative — a defensive copy per constant — + * would be the same set built at class-init time for no benefit. + */ + public Set certifies() { + return switch (this) { + case CONTRACT -> + Set.of( + "service-adapter", + "interceptor-order", + "status-mapping", + "validation", + "idempotency-replay", + "context-propagation"); + case TRANSPORT -> + Set.of( + "http2", + "tls", + "mutual-tls", + "metadata-limit", + "message-limit", + "goaway", + "keepalive", + "graceful-shutdown"); + case FAULT -> + Set.of("connection-loss", "completion-unknown", "partial-stream", "evidence-classifier"); + case PERFORMANCE -> + Set.of("latency", "stream-saturation", "executor-saturation", "drain-budget"); + }; + } + + /** + * Fails when {@code capability} is claimed on the strength of this grade. + * + * @throws IllegalStateException naming what this grade actually establishes + */ + public void requireCertifies(String capability) { + if (capability == null || capability.isBlank()) { + throw new IllegalArgumentException("a capability name is required"); + } + Set certified = certifies(); + if (!certified.contains(capability)) { + throw new IllegalStateException( + this + + " evidence does not certify '" + + capability + + "'; it establishes " + + certified.stream().sorted().toList()); + } + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcServerStreamingContract.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcServerStreamingContract.java new file mode 100644 index 00000000..0f59e864 --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcServerStreamingContract.java @@ -0,0 +1,61 @@ +package dev.caskeleton.grpc.testkit; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * The server-streaming suite and its aggregate verdict. + * + *

A missing scenario is a violation rather than a silent pass, which is the property that keeps + * the suite honest as it is edited. A streaming test that stops running is indistinguishable from + * one that passes, and the resume scenarios are the ones most likely to be skipped when they are + * slow. + */ +public final class GrpcServerStreamingContract { + + private final List scenarios; + + /** Binds a contract to its scenarios. */ + public GrpcServerStreamingContract(List scenarios) { + if (scenarios == null || scenarios.isEmpty()) { + throw new IllegalArgumentException( + "a streaming contract with no scenarios passes without checking anything"); + } + this.scenarios = List.copyOf(scenarios); + } + + /** The Stable suite. */ + public static GrpcServerStreamingContract stable() { + return new GrpcServerStreamingContract(GrpcStreamingScenario.stableSuite()); + } + + /** The scenarios this contract covers. */ + public List scenarios() { + return scenarios; + } + + /** + * Every violation across a run, including any scenario that did not run. + * + * @return an empty list when every scenario ran and held + */ + public List evaluate(List results) { + if (results == null) { + throw new IllegalArgumentException("a result list is required"); + } + List violations = new ArrayList<>(); + Set observed = new LinkedHashSet<>(); + results.forEach( + result -> { + observed.add(result.scenario().name()); + violations.addAll(result.violations()); + }); + scenarios.stream() + .map(GrpcStreamingScenario::name) + .filter(name -> !observed.contains(name)) + .forEach(name -> violations.add("streaming scenario '" + name + "' did not run")); + return List.copyOf(violations); + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcStreamingContractResult.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcStreamingContractResult.java new file mode 100644 index 00000000..5f93cefa --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcStreamingContractResult.java @@ -0,0 +1,81 @@ +package dev.caskeleton.grpc.testkit; + +import dev.caskeleton.grpc.streaming.GrpcStreamTerminationReason; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * What one streaming scenario observed. + * + *

Gaps and duplicates are counted separately, because they are opposite bugs with the same + * symptom of "the consumer's state is wrong": a gap means a message was never delivered, a + * duplicate means one was delivered twice, and a resume implementation typically produces one or + * the other depending on whether its cursor is inclusive. + */ +public record GrpcStreamingContractResult( + GrpcStreamingScenario scenario, + GrpcEvidenceGrade grade, + long messagesDelivered, + int sequenceGaps, + int duplicates, + GrpcStreamTerminationReason observedTermination, + Optional resumeCursor) { + + /** Requires the scenario, the grade and non-negative counts. */ + public GrpcStreamingContractResult { + if (scenario == null || grade == null || observedTermination == null || resumeCursor == null) { + throw new IllegalArgumentException("a streaming result carries its scenario and observation"); + } + if (messagesDelivered < 0 || sequenceGaps < 0 || duplicates < 0) { + throw new IllegalArgumentException("stream counts must not be negative"); + } + } + + /** + * Every way this run failed the scenario. + * + * @return an empty list when the contract held + */ + public List violations() { + List violations = new ArrayList<>(); + if (sequenceGaps > 0) { + violations.add( + scenario.name() + + ": " + + sequenceGaps + + " sequence gap(s); messages were never delivered"); + } + if (duplicates > 0) { + violations.add( + scenario.name() + ": " + duplicates + " duplicate(s); a message was delivered twice"); + } + if (observedTermination != scenario.expectedTermination()) { + violations.add( + scenario.name() + + ": expected termination " + + scenario.expectedTermination() + + ", observed " + + observedTermination); + } + if (scenario.expectResumable() && resumeCursor.isEmpty()) { + violations.add( + scenario.name() + + ": the stream ended resumably but carried no cursor, so a reconnect has nowhere to " + + "continue from"); + } + if (!scenario.expectResumable() && resumeCursor.isPresent()) { + violations.add( + scenario.name() + + ": the stream carried a resume cursor after ending " + + observedTermination + + ", which would let a client continue past messages that were lost"); + } + return List.copyOf(violations); + } + + /** Whether the contract held. */ + public boolean passed() { + return violations().isEmpty(); + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcStreamingScenario.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcStreamingScenario.java new file mode 100644 index 00000000..460b0644 --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcStreamingScenario.java @@ -0,0 +1,79 @@ +package dev.caskeleton.grpc.testkit; + +import dev.caskeleton.grpc.streaming.GrpcStreamTerminationReason; + +/** + * One server-streaming case: what the stream does, and how it is expected to end. + * + *

The five below are the properties a stream consumer actually depends on, and each has a + * failure mode that leaves the stream looking healthy. Ordering is the one nobody notices until a + * consumer's state diverges; resume-after-loss is the one that decides whether a reconnect is + * correct or silently skips messages. + */ +public record GrpcStreamingScenario( + String name, + Shape shape, + GrpcStreamTerminationReason expectedTermination, + boolean expectResumable) { + + /** What the scenario exercises. */ + public enum Shape { + /** Sequence numbers are monotonic with no gaps and no duplicates. */ + MONOTONIC_SEQUENCE, + /** A consumer that cannot keep up overflows the bounded queue. */ + SLOW_CONSUMER, + /** The connection drops mid-stream and the client reconnects with its token. */ + RESUME_AFTER_LOSS, + /** History behind the cursor is gone. */ + HISTORY_LOST, + /** The server drains and tells each stream where it stopped. */ + SERVER_DRAIN + } + + /** Refuses a scenario whose expectation contradicts its termination reason. */ + public GrpcStreamingScenario { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("a streaming scenario is named"); + } + if (shape == null || expectedTermination == null) { + throw new IllegalArgumentException("a streaming scenario states its shape and ending"); + } + if (expectResumable && !expectedTermination.resumable()) { + throw new IllegalArgumentException( + "a stream ending with " + + expectedTermination + + " cannot be resumed; continuing from the last delivered sequence would skip what " + + "was lost"); + } + } + + /** The Stable streaming suite. */ + public static java.util.List stableSuite() { + return java.util.List.of( + new GrpcStreamingScenario( + "monotonic-sequence", + Shape.MONOTONIC_SEQUENCE, + GrpcStreamTerminationReason.COMPLETED, + false), + new GrpcStreamingScenario( + "slow-consumer-terminates", + Shape.SLOW_CONSUMER, + GrpcStreamTerminationReason.SLOW_CONSUMER, + false), + new GrpcStreamingScenario( + "resume-after-loss", + Shape.RESUME_AFTER_LOSS, + GrpcStreamTerminationReason.IDLE_TIMEOUT, + true), + new GrpcStreamingScenario( + "history-lost-requires-resync", + Shape.HISTORY_LOST, + GrpcStreamTerminationReason.FULL_RESYNC_REQUIRED, + false), + new GrpcStreamingScenario( + "server-drain-carries-cursor", + Shape.SERVER_DRAIN, + GrpcStreamTerminationReason.SERVER_DRAIN, + true)); + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcTextCodec.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcTextCodec.java new file mode 100644 index 00000000..10f1ce5f --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcTextCodec.java @@ -0,0 +1,62 @@ +package dev.caskeleton.grpc.testkit; + +import io.grpc.MethodDescriptor; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; + +/** + * A UTF-8 marshaller, so the fixtures need no generated message types. + * + *

This repository compiles no protobuf (adaptation design D6), and the contracts these fixtures + * exercise — interceptor order, status mapping, metadata limits, stream sequencing — are properties + * of the transport and the platform rather than of any particular message shape. A text codec keeps + * the fixtures runnable today and swappable for generated stubs the day codegen is turned on. + */ +public final class GrpcTextCodec { + + /** The UTF-8 marshaller. */ + public static final MethodDescriptor.Marshaller MARSHALLER = + new MethodDescriptor.Marshaller<>() { + @Override + public InputStream stream(String value) { + return new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public String parse(InputStream stream) { + try { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + }; + + private GrpcTextCodec() {} + + /** A unary method descriptor for {@code fullMethodName}. */ + public static MethodDescriptor unary(String fullMethodName) { + return descriptor(fullMethodName, MethodDescriptor.MethodType.UNARY); + } + + /** A server-streaming method descriptor for {@code fullMethodName}. */ + public static MethodDescriptor serverStreaming(String fullMethodName) { + return descriptor(fullMethodName, MethodDescriptor.MethodType.SERVER_STREAMING); + } + + private static MethodDescriptor descriptor( + String fullMethodName, MethodDescriptor.MethodType type) { + if (fullMethodName == null || fullMethodName.isBlank()) { + throw new IllegalArgumentException("a method descriptor needs a full method name"); + } + return MethodDescriptor.newBuilder() + .setType(type) + .setFullMethodName(fullMethodName) + .setRequestMarshaller(MARSHALLER) + .setResponseMarshaller(MARSHALLER) + .build(); + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcUnaryContractResult.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcUnaryContractResult.java new file mode 100644 index 00000000..8a9b3450 --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcUnaryContractResult.java @@ -0,0 +1,69 @@ +package dev.caskeleton.grpc.testkit; + +import java.util.ArrayList; +import java.util.List; + +/** + * What a unary reliability scenario actually did. + * + *

{@code businessInvocations} is the number that decides whether the contract held, and it is + * counted at the use case rather than inferred from attempts. A retry that reaches the ledger and + * replays a stored outcome is two attempts and one invocation, which is exactly the shape the + * contract is asserting; counting attempts would call it a failure. + */ +public record GrpcUnaryContractResult( + GrpcUnaryScenario scenario, + GrpcEvidenceGrade grade, + int attempts, + int businessInvocations, + dev.caskeleton.grpc.error.GrpcCompletionOutcome observedOutcome) { + + /** Requires the scenario, the grade and non-negative counts. */ + public GrpcUnaryContractResult { + if (scenario == null || grade == null || observedOutcome == null) { + throw new IllegalArgumentException( + "a contract result carries its scenario, grade and outcome"); + } + if (attempts < 1 || businessInvocations < 0) { + throw new IllegalArgumentException("attempt and invocation counts must be coherent"); + } + } + + /** + * Every way this run failed the scenario. + * + * @return an empty list when the contract held + */ + public List violations() { + List violations = new ArrayList<>(); + if (attempts != scenario.expectedAttempts()) { + violations.add( + scenario.name() + + ": expected " + + scenario.expectedAttempts() + + " attempt(s), observed " + + attempts); + } + if (observedOutcome != scenario.expectedOutcome()) { + violations.add( + scenario.name() + + ": expected outcome " + + scenario.expectedOutcome() + + ", observed " + + observedOutcome); + } + if (businessInvocations > 1) { + violations.add( + scenario.name() + + ": the business operation ran " + + businessInvocations + + " times. This is the duplicate the reliability contract exists to prevent."); + } + return List.copyOf(violations); + } + + /** Whether the contract held. */ + public boolean passed() { + return violations().isEmpty(); + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcUnaryReliabilityContract.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcUnaryReliabilityContract.java new file mode 100644 index 00000000..ac071f8e --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcUnaryReliabilityContract.java @@ -0,0 +1,81 @@ +package dev.caskeleton.grpc.testkit; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.deadline.GrpcDeadlinePolicyValidator; +import dev.caskeleton.grpc.deadline.GrpcDependencyBudget; +import dev.caskeleton.grpc.policy.GrpcMethodPolicyCatalog; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The unary reliability suite: a set of scenarios and the aggregate verdict. + * + *

Also runs the deadline coherence check, because it belongs to the same claim. "This method is + * reliable" is not true of a method whose database timeout is longer than its own deadline, however + * well its retries behave — the dependency timeout never fires, and the failure the suite observes + * is attributed to the wrong system. + */ +public final class GrpcUnaryReliabilityContract { + + private final List scenarios; + + /** Binds a contract to its scenarios. */ + public GrpcUnaryReliabilityContract(List scenarios) { + if (scenarios == null || scenarios.isEmpty()) { + throw new IllegalArgumentException( + "a reliability contract with no scenarios passes without checking anything"); + } + this.scenarios = List.copyOf(scenarios); + } + + /** The three Stable shapes: read, non-idempotent mutation, keyed mutation. */ + public static GrpcUnaryReliabilityContract stable( + GrpcMethodName read, GrpcMethodName nonIdempotent, GrpcMethodName keyed) { + return new GrpcUnaryReliabilityContract( + List.of( + GrpcUnaryScenario.readRetriesOnUnavailable(read), + GrpcUnaryScenario.nonIdempotentIsNotRetried(nonIdempotent), + GrpcUnaryScenario.keyedMutationReplaysAfterCommitLoss(keyed))); + } + + /** The scenarios this contract covers. */ + public List scenarios() { + return scenarios; + } + + /** + * Every violation across a run's results, including any missing scenario. + * + * @return an empty list when every scenario ran and held + */ + public List evaluate(List results) { + if (results == null) { + throw new IllegalArgumentException("a result list is required"); + } + List violations = new ArrayList<>(); + Set observed = new java.util.LinkedHashSet<>(); + results.forEach( + result -> { + observed.add(result.scenario().name()); + violations.addAll(result.violations()); + }); + scenarios.stream() + .map(GrpcUnaryScenario::name) + .filter(name -> !observed.contains(name)) + .forEach(name -> violations.add("scenario '" + name + "' did not run")); + return List.copyOf(violations); + } + + /** + * Whether every dependency timeout fits inside the deadline of the method that waits on it. + * + * @return an empty list when the configuration is coherent + */ + public static List deadlineCoherence( + GrpcMethodPolicyCatalog catalog, + Map> dependenciesByMethod) { + return GrpcDeadlinePolicyValidator.validate(catalog, dependenciesByMethod); + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcUnaryScenario.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcUnaryScenario.java new file mode 100644 index 00000000..178ea21c --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/GrpcUnaryScenario.java @@ -0,0 +1,82 @@ +package dev.caskeleton.grpc.testkit; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.GrpcStatusCode; +import dev.caskeleton.grpc.error.GrpcCompletionOutcome; +import dev.caskeleton.grpc.policy.RpcIdempotencyProfile; + +/** + * One unary reliability case: a method shape, a failure, and what must happen next. + * + *

The three shapes below are the ones the Stable plan requires evidence for, and they are the + * three whose correct behaviour differs. Writing them as data rather than as three test methods + * means the suite enumerates its coverage instead of implying it. + */ +public record GrpcUnaryScenario( + String name, + GrpcMethodName method, + RpcIdempotencyProfile idempotency, + GrpcStatusCode failureStatus, + int expectedAttempts, + GrpcCompletionOutcome expectedOutcome, + boolean expectBusinessRunTwice) { + + /** Refuses a scenario that expects a duplicate business effect. */ + public GrpcUnaryScenario { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("a unary scenario is named"); + } + if (method == null || idempotency == null || failureStatus == null || expectedOutcome == null) { + throw new IllegalArgumentException( + "a unary scenario states its method, failure and expectation"); + } + if (expectedAttempts < 1) { + throw new IllegalArgumentException("a scenario expects at least one attempt"); + } + if (expectBusinessRunTwice) { + throw new IllegalArgumentException( + "no scenario may expect the business operation to run twice; that is the outcome the " + + "whole reliability contract exists to prevent"); + } + if (idempotency == RpcIdempotencyProfile.NON_IDEMPOTENT && expectedAttempts > 1) { + throw new IllegalArgumentException( + "a NON_IDEMPOTENT method may not be attempted more than once"); + } + } + + /** A read that retries within its budget and then succeeds. */ + public static GrpcUnaryScenario readRetriesOnUnavailable(GrpcMethodName method) { + return new GrpcUnaryScenario( + "read-retries-on-unavailable", + method, + RpcIdempotencyProfile.READ_ONLY, + GrpcStatusCode.UNAVAILABLE, + 3, + GrpcCompletionOutcome.COMPLETED, + false); + } + + /** A non-idempotent mutation that is not retried. */ + public static GrpcUnaryScenario nonIdempotentIsNotRetried(GrpcMethodName method) { + return new GrpcUnaryScenario( + "non-idempotent-is-not-retried", + method, + RpcIdempotencyProfile.NON_IDEMPOTENT, + GrpcStatusCode.UNAVAILABLE, + 1, + GrpcCompletionOutcome.COMPLETION_UNKNOWN, + false); + } + + /** A keyed mutation whose response was lost after commit. */ + public static GrpcUnaryScenario keyedMutationReplaysAfterCommitLoss(GrpcMethodName method) { + return new GrpcUnaryScenario( + "keyed-mutation-replays-after-commit-loss", + method, + RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED, + GrpcStatusCode.DEADLINE_EXCEEDED, + 1, + GrpcCompletionOutcome.COMPLETION_UNKNOWN, + false); + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/fault/GrpcFaultPoint.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/fault/GrpcFaultPoint.java new file mode 100644 index 00000000..5b8871b8 --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/fault/GrpcFaultPoint.java @@ -0,0 +1,53 @@ +package dev.caskeleton.grpc.testkit.fault; + +import dev.caskeleton.grpc.evidence.GrpcBusinessEvidence; +import dev.caskeleton.grpc.evidence.GrpcTransportEvidence; + +/** + * Where a connection is cut, relative to what the server had already done. + * + *

These are the boundaries the three-axis evidence model exists to distinguish, and they are + * only distinguishable by injecting a fault at each one. A test suite that kills connections at a + * random moment exercises whichever boundary it happened to hit; naming them makes the coverage a + * list rather than a hope. + */ +public enum GrpcFaultPoint { + /** Before any byte left the client. */ + BEFORE_SEND(GrpcTransportEvidence.NOT_SENT, GrpcBusinessEvidence.NONE), + /** After the request was written, before the server accepted it. */ + AFTER_SEND(GrpcTransportEvidence.SENT_UNCONFIRMED, GrpcBusinessEvidence.NONE), + /** The server started application work. */ + APP_STARTED(GrpcTransportEvidence.SENT_UNCONFIRMED, GrpcBusinessEvidence.ATTEMPTED), + /** The business transaction committed, and then the connection died. */ + AFTER_COMMIT(GrpcTransportEvidence.SENT_UNCONFIRMED, GrpcBusinessEvidence.COMMIT_CONFIRMED), + /** Response headers reached the client. */ + AFTER_HEADERS(GrpcTransportEvidence.RESPONSE_HEADERS_SEEN, GrpcBusinessEvidence.ATTEMPTED), + /** A response message reached the client. */ + AFTER_MESSAGE(GrpcTransportEvidence.RESPONSE_MESSAGE_SEEN, GrpcBusinessEvidence.ATTEMPTED), + /** Everything but the trailers arrived. */ + BEFORE_TRAILERS(GrpcTransportEvidence.RESPONSE_MESSAGE_SEEN, GrpcBusinessEvidence.ATTEMPTED); + + private final GrpcTransportEvidence serverSideTransport; + private final GrpcBusinessEvidence serverSideBusiness; + + GrpcFaultPoint( + GrpcTransportEvidence serverSideTransport, GrpcBusinessEvidence serverSideBusiness) { + this.serverSideTransport = serverSideTransport; + this.serverSideBusiness = serverSideBusiness; + } + + /** What the server had actually done, which the client cannot see. */ + public GrpcTransportEvidence serverSideTransport() { + return serverSideTransport; + } + + /** What the business had actually done, which the client cannot see. */ + public GrpcBusinessEvidence serverSideBusiness() { + return serverSideBusiness; + } + + /** Whether a mutation cut here may already have committed. */ + public boolean mayHaveCommitted() { + return this != BEFORE_SEND; + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/fault/GrpcFaultResult.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/fault/GrpcFaultResult.java new file mode 100644 index 00000000..d3078ce0 --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/fault/GrpcFaultResult.java @@ -0,0 +1,58 @@ +package dev.caskeleton.grpc.testkit.fault; + +import dev.caskeleton.grpc.error.GrpcCompletionOutcome; +import dev.caskeleton.grpc.evidence.GrpcExecutionEvidence; +import dev.caskeleton.grpc.testkit.GrpcEvidenceGrade; +import java.util.Optional; + +/** + * What one injected fault produced, and whether it matched the scenario's expectation. + * + *

Carries the grade so a report cannot silently cite an in-process run as fault evidence. The + * fault suite's whole value is that it uses real sockets; a run that did not is a run that proved + * the classifier's arithmetic, not the platform's behaviour. + */ +public record GrpcFaultResult( + GrpcFaultScenario scenario, + GrpcEvidenceGrade grade, + GrpcExecutionEvidence observedEvidence, + GrpcCompletionOutcome observedOutcome, + Optional observedLastSequence) { + + /** Requires the grade to be a fault grade. */ + public GrpcFaultResult { + if (scenario == null + || grade == null + || observedEvidence == null + || observedOutcome == null + || observedLastSequence == null) { + throw new IllegalArgumentException( + "a fault result carries its scenario, grade and observation"); + } + if (grade != GrpcEvidenceGrade.FAULT) { + throw new IllegalArgumentException( + "a fault result must be " + + GrpcEvidenceGrade.FAULT + + " evidence; " + + grade + + " did not inject a real network fault"); + } + } + + /** Whether the observed outcome is what the scenario expected. */ + public boolean matchedExpectation() { + return observedOutcome == scenario.expectedOutcome(); + } + + /** A one-line report line. */ + public String describe() { + return scenario.name() + + " at " + + scenario.faultPoint() + + ": expected " + + scenario.expectedOutcome() + + ", observed " + + observedOutcome + + (matchedExpectation() ? " (match)" : " (MISMATCH)"); + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/fault/GrpcFaultScenario.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/fault/GrpcFaultScenario.java new file mode 100644 index 00000000..1caa8cf8 --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/fault/GrpcFaultScenario.java @@ -0,0 +1,66 @@ +package dev.caskeleton.grpc.testkit.fault; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.error.GrpcCompletionOutcome; +import dev.caskeleton.grpc.policy.RpcIdempotencyProfile; + +/** + * One fault to inject, and what the client is expected to conclude from it. + * + *

The expectation is part of the scenario rather than assembled in the test, because the whole + * point of the suite is that the same fault means different things for different methods: a + * connection cut after commit is {@code COMPLETION_UNKNOWN} for a mutation and simply a failed read + * for a query, and a suite that expects one answer everywhere passes while the distinction it + * exists to check is broken. + */ +public record GrpcFaultScenario( + String name, + GrpcMethodName method, + RpcIdempotencyProfile idempotency, + GrpcFaultPoint faultPoint, + GrpcCompletionOutcome expectedOutcome, + boolean expectRetryPermitted) { + + /** Refuses a scenario whose expectation contradicts its own fault point. */ + public GrpcFaultScenario { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("a fault scenario is named"); + } + if (method == null || idempotency == null || faultPoint == null || expectedOutcome == null) { + throw new IllegalArgumentException( + "a fault scenario states its method, fault and expectation"); + } + if (faultPoint == GrpcFaultPoint.BEFORE_SEND + && expectedOutcome == GrpcCompletionOutcome.COMPLETION_UNKNOWN) { + throw new IllegalArgumentException( + "a request the client watched fail to send cannot be COMPLETION_UNKNOWN; the client " + + "observed that nothing was sent"); + } + if (idempotency == RpcIdempotencyProfile.NON_IDEMPOTENT && expectRetryPermitted) { + throw new IllegalArgumentException( + "a scenario cannot expect a retry on a NON_IDEMPOTENT method"); + } + } + + /** A mutation whose commit succeeded and whose response was lost. */ + public static GrpcFaultScenario commitResponseLost(GrpcMethodName method) { + return new GrpcFaultScenario( + "commit-response-lost", + method, + RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED, + GrpcFaultPoint.AFTER_COMMIT, + GrpcCompletionOutcome.COMPLETION_UNKNOWN, + false); + } + + /** A read whose connection failed before anything was sent. */ + public static GrpcFaultScenario readNeverSent(GrpcMethodName method) { + return new GrpcFaultScenario( + "read-never-sent", + method, + RpcIdempotencyProfile.READ_ONLY, + GrpcFaultPoint.BEFORE_SEND, + GrpcCompletionOutcome.REJECTED, + true); + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/fault/GrpcTransportEvidenceClassifier.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/fault/GrpcTransportEvidenceClassifier.java new file mode 100644 index 00000000..3d77970c --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/fault/GrpcTransportEvidenceClassifier.java @@ -0,0 +1,107 @@ +package dev.caskeleton.grpc.testkit.fault; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.GrpcStatusCode; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.error.GrpcCompletionOutcome; +import dev.caskeleton.grpc.evidence.GrpcBusinessEvidence; +import dev.caskeleton.grpc.evidence.GrpcExecutionEvidence; +import dev.caskeleton.grpc.evidence.GrpcStreamEvidence; +import dev.caskeleton.grpc.evidence.GrpcTransportEvidence; +import java.util.Optional; + +/** + * Turns what a client observed into evidence, refusing to fill in what it did not. + * + *

The rule the classifier exists to hold: an unobserved state is {@code UNOBSERVED}, never + * {@code NOT_SENT}. The two look the same from a stack trace — both are an exception on a call that + * produced no response — and treating the second as the first is the reasoning that replays a + * committed mutation. Only a client that watched its own send fail may claim NOT_SENT. + */ +public final class GrpcTransportEvidenceClassifier { + + private GrpcTransportEvidenceClassifier() {} + + /** What the client actually observed, as distinct from what it can infer. */ + public record ClientObservation( + boolean sendCompleted, + boolean responseHeadersReceived, + boolean responseMessageReceived, + boolean trailersReceived, + Optional lastStreamSequence) { + + /** Requires the Optional and a coherent progression. */ + public ClientObservation { + if (lastStreamSequence == null) { + throw new IllegalArgumentException("the stream sequence Optional must be present"); + } + if (responseMessageReceived && !responseHeadersReceived) { + throw new IllegalArgumentException( + "a response message cannot arrive before its headers; this observation is not one a " + + "client could have made"); + } + } + + /** The client watched the send fail. */ + public static ClientObservation sendFailed() { + return new ClientObservation(false, false, false, false, Optional.empty()); + } + + /** The client sent and heard nothing back. */ + public static ClientObservation sentAndSilent() { + return new ClientObservation(true, false, false, false, Optional.empty()); + } + } + + /** + * The evidence a client is entitled to record. + * + * @param sendObserved whether the client actually watched its own send outcome. False when the + * call failed in a way that says nothing about the send — a process kill, a thread interrupt + * — in which case the transport axis is UNOBSERVED rather than NOT_SENT. + */ + public static GrpcExecutionEvidence classify( + GrpcMethodName method, + RpcType rpcType, + ClientObservation observation, + boolean sendObserved, + GrpcBusinessEvidence businessEvidence) { + if (method == null || rpcType == null || observation == null || businessEvidence == null) { + throw new IllegalArgumentException("classification needs the method, shape and observation"); + } + GrpcTransportEvidence transport = transportAxis(observation, sendObserved); + GrpcStreamEvidence stream = + observation + .lastStreamSequence() + .map(GrpcStreamEvidence.Partial::new) + .orElseGet(GrpcStreamEvidence::none); + GrpcBusinessEvidence business = + transport == GrpcTransportEvidence.NOT_SENT ? GrpcBusinessEvidence.NONE : businessEvidence; + return new GrpcExecutionEvidence(method, rpcType, transport, business, stream); + } + + private static GrpcTransportEvidence transportAxis( + ClientObservation observation, boolean sendObserved) { + if (observation.trailersReceived()) { + return GrpcTransportEvidence.TRAILERS_SEEN; + } + if (observation.responseMessageReceived()) { + return GrpcTransportEvidence.RESPONSE_MESSAGE_SEEN; + } + if (observation.responseHeadersReceived()) { + return GrpcTransportEvidence.RESPONSE_HEADERS_SEEN; + } + if (observation.sendCompleted()) { + return GrpcTransportEvidence.SENT_UNCONFIRMED; + } + // The branch that matters. Only a client that watched its own send fail may say NOT_SENT; + // everything else is a state nobody observed. + return sendObserved ? GrpcTransportEvidence.NOT_SENT : GrpcTransportEvidence.UNOBSERVED; + } + + /** What the caller may conclude about a mutation, given the classified evidence. */ + public static GrpcCompletionOutcome outcomeFor( + GrpcStatusCode statusCode, GrpcExecutionEvidence evidence) { + return GrpcCompletionOutcome.forMutation(statusCode, evidence); + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/inprocess/GrpcInProcessContractFixture.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/inprocess/GrpcInProcessContractFixture.java new file mode 100644 index 00000000..e40f4a2b --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/inprocess/GrpcInProcessContractFixture.java @@ -0,0 +1,145 @@ +package dev.caskeleton.grpc.testkit.inprocess; + +import dev.caskeleton.grpc.testkit.GrpcEvidenceGrade; +import dev.caskeleton.grpc.testkit.GrpcTextCodec; +import io.grpc.ClientInterceptor; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerServiceDefinition; +import io.grpc.Status; +import io.grpc.stub.ServerCalls; +import io.grpc.stub.StreamObserver; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +/** + * A running in-process server and client, wired together and closed as one. + * + *

Its {@link #grade()} is {@link GrpcEvidenceGrade#CONTRACT}, and asking it to certify a + * transport capability throws. That refusal is the module boundary the Stable plan drew between its + * in-process and Netty testkits, kept as a runtime check because a lane separation alone cannot + * stop a report from citing the wrong run. + */ +public final class GrpcInProcessContractFixture implements AutoCloseable { + + private final GrpcInProcessTestServer server; + private final GrpcInProcessTestClient client; + + private GrpcInProcessContractFixture( + GrpcInProcessTestServer server, GrpcInProcessTestClient client) { + this.server = server; + this.client = client; + } + + /** Starts a fixture serving one unary method that applies {@code handler} to the request. */ + public static GrpcInProcessContractFixture unary( + MethodDescriptor method, + Function handler, + List serverInterceptors, + List clientInterceptors) { + ServerServiceDefinition service = + ServerServiceDefinition.builder(serviceNameOf(method)) + .addMethod(method, unaryHandler(handler)) + .build(); + GrpcInProcessTestServer startedServer = + GrpcInProcessTestServer.start(List.of(service), serverInterceptors); + return new GrpcInProcessContractFixture( + startedServer, GrpcInProcessTestClient.connect(startedServer, clientInterceptors)); + } + + /** Starts a fixture serving one server-streaming method that emits {@code responses}. */ + public static GrpcInProcessContractFixture serverStreaming( + MethodDescriptor method, + Function> handler, + List serverInterceptors) { + ServerServiceDefinition service = + ServerServiceDefinition.builder(serviceNameOf(method)) + .addMethod(method, serverStreamingHandler(handler)) + .build(); + GrpcInProcessTestServer startedServer = + GrpcInProcessTestServer.start(List.of(service), serverInterceptors); + return new GrpcInProcessContractFixture( + startedServer, GrpcInProcessTestClient.connect(startedServer, List.of())); + } + + /** What this fixture's results are evidence about. */ + public GrpcEvidenceGrade grade() { + return GrpcEvidenceGrade.CONTRACT; + } + + /** The client. */ + public GrpcInProcessTestClient client() { + return client; + } + + /** The server. */ + public GrpcInProcessTestServer server() { + return server; + } + + /** A unary method descriptor under this fixture's conventions. */ + public static MethodDescriptor unaryMethod(String fullMethodName) { + return GrpcTextCodec.unary(fullMethodName); + } + + /** A server-streaming method descriptor under this fixture's conventions. */ + public static MethodDescriptor streamingMethod(String fullMethodName) { + return GrpcTextCodec.serverStreaming(fullMethodName); + } + + @Override + public void close() { + List failures = new ArrayList<>(); + try { + client.close(); + } catch (RuntimeException failure) { + failures.add(failure); + } + try { + server.close(); + } catch (RuntimeException failure) { + failures.add(failure); + } + if (!failures.isEmpty()) { + RuntimeException first = failures.get(0); + failures.stream().skip(1).forEach(first::addSuppressed); + throw first; + } + } + + private static String serviceNameOf(MethodDescriptor method) { + String serviceName = MethodDescriptor.extractFullServiceName(method.getFullMethodName()); + if (serviceName == null) { + throw new IllegalArgumentException( + "method '" + method.getFullMethodName() + "' has no service name"); + } + return serviceName; + } + + private static ServerCallHandler unaryHandler(Function handler) { + return ServerCalls.asyncUnaryCall( + (String request, StreamObserver responseObserver) -> { + try { + responseObserver.onNext(handler.apply(request)); + responseObserver.onCompleted(); + } catch (RuntimeException failure) { + responseObserver.onError(Status.fromThrowable(failure).asRuntimeException()); + } + }); + } + + private static ServerCallHandler serverStreamingHandler( + Function> handler) { + return ServerCalls.asyncServerStreamingCall( + (String request, StreamObserver responseObserver) -> { + try { + handler.apply(request).forEach(responseObserver::onNext); + responseObserver.onCompleted(); + } catch (RuntimeException failure) { + responseObserver.onError(Status.fromThrowable(failure).asRuntimeException()); + } + }); + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/inprocess/GrpcInProcessTestClient.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/inprocess/GrpcInProcessTestClient.java new file mode 100644 index 00000000..7192c7a7 --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/inprocess/GrpcInProcessTestClient.java @@ -0,0 +1,83 @@ +package dev.caskeleton.grpc.testkit.inprocess; + +import io.grpc.CallOptions; +import io.grpc.ClientInterceptor; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.MetadataUtils; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * A client for the in-process fixture. + * + *

Uses {@code ClientCalls} directly rather than a generated stub, for the same reason the + * fixture uses a text codec: the contracts under test are the platform's, and binding them to + * generated types would mean the suite cannot run until codegen is switched on. + */ +public final class GrpcInProcessTestClient implements AutoCloseable { + + private final ManagedChannel channel; + + private GrpcInProcessTestClient(ManagedChannel channel) { + this.channel = channel; + } + + /** Connects to a running in-process server. */ + public static GrpcInProcessTestClient connect( + GrpcInProcessTestServer server, List interceptors) { + if (server == null) { + throw new IllegalArgumentException("a client needs a server to connect to"); + } + InProcessChannelBuilder builder = + InProcessChannelBuilder.forName(server.serverName()).directExecutor(); + if (interceptors != null && !interceptors.isEmpty()) { + builder.intercept(interceptors.reversed()); + } + return new GrpcInProcessTestClient(builder.build()); + } + + /** Calls a unary method and returns the response. */ + public String callUnary(MethodDescriptor method, String request) { + return ClientCalls.blockingUnaryCall(channel, method, CallOptions.DEFAULT, request); + } + + /** + * Calls a unary method with metadata attached. + * + *

The headers go on through an interceptor rather than a call option, because gRPC has no + * per-call header parameter: headers are produced by the call itself, and attaching them is what + * an interceptor is for. + */ + public String callUnary( + MethodDescriptor method, String request, Metadata headers) { + return ClientCalls.blockingUnaryCall( + io.grpc.ClientInterceptors.intercept( + channel, MetadataUtils.newAttachHeadersInterceptor(headers)), + method, + CallOptions.DEFAULT, + request); + } + + /** Collects a server stream into a list. */ + public List callServerStreaming(MethodDescriptor method, String request) { + java.util.Iterator responses = + ClientCalls.blockingServerStreamingCall(channel, method, CallOptions.DEFAULT, request); + List collected = new java.util.ArrayList<>(); + responses.forEachRemaining(collected::add); + return List.copyOf(collected); + } + + @Override + public void close() { + channel.shutdownNow(); + try { + channel.awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/inprocess/GrpcInProcessTestServer.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/inprocess/GrpcInProcessTestServer.java new file mode 100644 index 00000000..f31203da --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/inprocess/GrpcInProcessTestServer.java @@ -0,0 +1,94 @@ +package dev.caskeleton.grpc.testkit.inprocess; + +import io.grpc.BindableService; +import io.grpc.Server; +import io.grpc.ServerInterceptor; +import io.grpc.ServerServiceDefinition; +import io.grpc.inprocess.InProcessServerBuilder; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * An in-process server for contract tests. + * + *

Its name is generated per instance. A shared constant is the standard way this fixture breaks: + * two tests running in the same JVM bind the same name, the second silently reaches the first's + * server, and the failure appears as an unrelated test's assertion. + * + *

{@link AutoCloseable}, and {@link #close()} shuts down and waits. A leaked in-process server + * keeps its executor threads alive for the rest of the JVM, which turns a leak in one test into a + * timeout in a later one. + */ +public final class GrpcInProcessTestServer implements AutoCloseable { + + private final String serverName; + private final Server server; + + private GrpcInProcessTestServer(String serverName, Server server) { + this.serverName = serverName; + this.server = server; + } + + /** + * Starts a server with the given services and interceptors. + * + * @param interceptors in Stable order, outermost first. Reversed here, because {@code + * ServerInterceptors} wraps the last one outermost. + */ + public static GrpcInProcessTestServer start( + List services, List interceptors) { + if (services == null || services.isEmpty()) { + throw new IllegalArgumentException("a test server needs at least one service"); + } + if (interceptors == null) { + throw new IllegalArgumentException("an interceptor list is required, even if empty"); + } + String serverName = "grpc-testkit-" + UUID.randomUUID(); + InProcessServerBuilder builder = InProcessServerBuilder.forName(serverName).directExecutor(); + services.forEach( + service -> + builder.addService( + io.grpc.ServerInterceptors.intercept(service, interceptors.reversed()))); + try { + return new GrpcInProcessTestServer(serverName, builder.build().start()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** Starts a server from bindable services. */ + public static GrpcInProcessTestServer startBindable( + List services, List interceptors) { + return start(services.stream().map(BindableService::bindService).toList(), interceptors); + } + + /** The generated server name, which the client needs. */ + public String serverName() { + return serverName; + } + + /** Whether the server is still up. */ + public boolean running() { + return !server.isShutdown(); + } + + @Override + public void close() { + server.shutdownNow(); + try { + if (!server.awaitTermination(5, TimeUnit.SECONDS)) { + throw new IllegalStateException( + "in-process server '" + + serverName + + "' did not terminate; its threads would outlive " + + "this test and time out a later one"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while shutting down " + serverName, interrupted); + } + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/netty/GrpcNettyContractProfile.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/netty/GrpcNettyContractProfile.java new file mode 100644 index 00000000..aa84197b --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/netty/GrpcNettyContractProfile.java @@ -0,0 +1,87 @@ +package dev.caskeleton.grpc.testkit.netty; + +import dev.caskeleton.grpc.server.GrpcNettyVariant; +import dev.caskeleton.grpc.testkit.GrpcEvidenceGrade; +import java.time.Duration; + +/** + * What a Netty contract run is configured to prove. + * + *

The limits are deliberately small. A metadata limit of eight kilobytes is not exceeded by + * anything a test would naturally send, so a suite that wants to observe the transport enforcing it + * has to configure a limit it can cross on purpose. + */ +public record GrpcNettyContractProfile( + GrpcNettyVariant variant, + boolean tls, + boolean mutualTls, + int maxInboundMetadataBytes, + int maxInboundMessageBytes, + Duration keepAliveTime, + Duration maxConnectionAge) { + + /** Refuses a profile that could not observe what it claims to test. */ + public GrpcNettyContractProfile { + if (variant == null) { + throw new IllegalArgumentException("a Netty contract profile names its variant"); + } + if (mutualTls && !tls) { + throw new IllegalArgumentException("mTLS without TLS is not a thing"); + } + if (maxInboundMetadataBytes < 1 || maxInboundMessageBytes < 1) { + throw new IllegalArgumentException("transport limits must be positive to be observable"); + } + if (keepAliveTime == null || keepAliveTime.isZero() || keepAliveTime.isNegative()) { + throw new IllegalArgumentException("a keep-alive interval must be positive"); + } + if (maxConnectionAge == null || maxConnectionAge.isZero() || maxConnectionAge.isNegative()) { + throw new IllegalArgumentException("a max connection age must be positive"); + } + } + + /** Plaintext on an ephemeral port, with limits small enough to cross deliberately. */ + public static GrpcNettyContractProfile plaintext() { + return new GrpcNettyContractProfile( + GrpcNettyVariant.SHADED, + false, + false, + 1024, + 4096, + Duration.ofSeconds(5), + Duration.ofMinutes(5)); + } + + /** Server-authenticated TLS. */ + public static GrpcNettyContractProfile serverAuthenticated() { + return new GrpcNettyContractProfile( + GrpcNettyVariant.SHADED, + true, + false, + 1024, + 4096, + Duration.ofSeconds(5), + Duration.ofMinutes(5)); + } + + /** + * Mutual TLS. + * + *

Named {@code mutuallyAuthenticated} rather than {@code mutualTls}, which the record already + * uses for its accessor. + */ + public static GrpcNettyContractProfile mutuallyAuthenticated() { + return new GrpcNettyContractProfile( + GrpcNettyVariant.SHADED, + true, + true, + 1024, + 4096, + Duration.ofSeconds(5), + Duration.ofMinutes(5)); + } + + /** What a run under this profile is evidence about. */ + public GrpcEvidenceGrade grade() { + return GrpcEvidenceGrade.TRANSPORT; + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/netty/GrpcNettyTestClient.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/netty/GrpcNettyTestClient.java new file mode 100644 index 00000000..1d572420 --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/netty/GrpcNettyTestClient.java @@ -0,0 +1,116 @@ +package dev.caskeleton.grpc.testkit.netty; + +import io.grpc.CallOptions; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.shaded.io.grpc.netty.NegotiationType; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.MetadataUtils; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * A real Netty client for the transport contract suite. + * + *

{@code overrideAuthority} is exposed on purpose: connecting under a name the certificate was + * not issued for is how the hostname-verification case is exercised, and there is no way to test it + * without being able to lie about the authority. + */ +public final class GrpcNettyTestClient implements AutoCloseable { + + private final ManagedChannel channel; + + private GrpcNettyTestClient(ManagedChannel channel) { + this.channel = channel; + } + + /** + * Connects to a running Netty test server. + * + * @param authorityOverride the authority to present, or empty to use the address. Present only + * when a test is deliberately exercising hostname verification. + */ + public static GrpcNettyTestClient connect( + GrpcNettyTestServer server, + Optional tlsMaterial, + Optional authorityOverride) { + if (server == null || tlsMaterial == null || authorityOverride == null) { + throw new IllegalArgumentException("a Netty client needs a server and both Optionals"); + } + NettyChannelBuilder builder = + NettyChannelBuilder.forAddress( + new InetSocketAddress(InetAddress.getLoopbackAddress(), server.port())) + .maxInboundMetadataSize(server.profile().maxInboundMetadataBytes()) + .maxInboundMessageSize(server.profile().maxInboundMessageBytes()); + + if (server.profile().tls()) { + GrpcTlsTestMaterial material = + tlsMaterial.orElseThrow( + () -> new IllegalArgumentException("a TLS server needs the client's trust material")); + builder + .negotiationType(NegotiationType.TLS) + .sslContext(clientSslContext(server, material)) + .overrideAuthority(authorityOverride.orElse(material.hostname())); + } else { + builder.usePlaintext(); + authorityOverride.ifPresent(builder::overrideAuthority); + } + return new GrpcNettyTestClient(builder.build()); + } + + private static io.grpc.netty.shaded.io.netty.handler.ssl.SslContext clientSslContext( + GrpcNettyTestServer server, GrpcTlsTestMaterial material) { + try { + SslContextBuilder sslContext = + SslContextBuilder.forClient().trustManager(material.trustManagerFactory()); + if (server.profile().mutualTls()) { + sslContext.keyManager(material.keyManagerFactory()); + } + return GrpcSslContexts.configure(sslContext).build(); + } catch (javax.net.ssl.SSLException e) { + throw new IllegalStateException("could not build the test client SSL context", e); + } + } + + /** Calls a unary method. */ + public String callUnary(MethodDescriptor method, String request) { + return ClientCalls.blockingUnaryCall(channel, method, CallOptions.DEFAULT, request); + } + + /** Calls a unary method with metadata attached. */ + public String callUnary( + MethodDescriptor method, String request, Metadata headers) { + return ClientCalls.blockingUnaryCall( + io.grpc.ClientInterceptors.intercept( + channel, MetadataUtils.newAttachHeadersInterceptor(headers)), + method, + CallOptions.DEFAULT, + request); + } + + /** Collects a server stream. */ + public List callServerStreaming(MethodDescriptor method, String request) { + java.util.Iterator responses = + ClientCalls.blockingServerStreamingCall(channel, method, CallOptions.DEFAULT, request); + List collected = new java.util.ArrayList<>(); + responses.forEachRemaining(collected::add); + return List.copyOf(collected); + } + + @Override + public void close() { + channel.shutdownNow(); + try { + channel.awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/netty/GrpcNettyTestServer.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/netty/GrpcNettyTestServer.java new file mode 100644 index 00000000..f326e23f --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/netty/GrpcNettyTestServer.java @@ -0,0 +1,137 @@ +package dev.caskeleton.grpc.testkit.netty; + +import io.grpc.Server; +import io.grpc.ServerInterceptor; +import io.grpc.ServerInterceptors; +import io.grpc.ServerServiceDefinition; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.netty.shaded.io.netty.handler.ssl.ClientAuth; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * A real Netty server on an ephemeral loopback port. + * + *

Ephemeral because a fixed port is a fixture that fails on a machine already using it, and + * loopback because a test server bound to a wildcard address is reachable from the network the test + * machine is on. + * + *

This is the only fixture whose results are transport evidence. Everything it does — the HTTP/2 + * handshake, the TLS negotiation, the metadata and message limits, the GOAWAY on shutdown — is + * skipped entirely by the in-process transport. + */ +public final class GrpcNettyTestServer implements AutoCloseable { + + private final Server server; + private final GrpcNettyContractProfile profile; + + private GrpcNettyTestServer(Server server, GrpcNettyContractProfile profile) { + this.server = server; + this.profile = profile; + } + + /** + * Starts a server under {@code profile}. + * + * @param tlsMaterial required when the profile uses TLS, and refused when it does not + */ + public static GrpcNettyTestServer start( + GrpcNettyContractProfile profile, + List services, + List interceptors, + Optional tlsMaterial) { + if (profile == null + || services == null + || services.isEmpty() + || interceptors == null + || tlsMaterial == null) { + throw new IllegalArgumentException( + "a Netty test server needs a profile, services and the Optional"); + } + if (profile.tls() && tlsMaterial.isEmpty()) { + throw new IllegalArgumentException("a TLS profile needs TLS material"); + } + if (!profile.tls() && tlsMaterial.isPresent()) { + throw new IllegalArgumentException( + "a plaintext profile was given TLS material; the fixture would report TLS evidence it did " + + "not produce"); + } + + NettyServerBuilder builder = + NettyServerBuilder.forAddress(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)) + .maxInboundMetadataSize(profile.maxInboundMetadataBytes()) + .maxInboundMessageSize(profile.maxInboundMessageBytes()) + .keepAliveTime(profile.keepAliveTime().toMillis(), TimeUnit.MILLISECONDS) + .maxConnectionAge(profile.maxConnectionAge().toMillis(), TimeUnit.MILLISECONDS); + services.forEach( + service -> + builder.addService(ServerInterceptors.intercept(service, interceptors.reversed()))); + tlsMaterial.ifPresent(material -> builder.sslContext(serverSslContext(profile, material))); + + try { + return new GrpcNettyTestServer(builder.build().start(), profile); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static io.grpc.netty.shaded.io.netty.handler.ssl.SslContext serverSslContext( + GrpcNettyContractProfile profile, GrpcTlsTestMaterial material) { + try { + SslContextBuilder sslContext = SslContextBuilder.forServer(material.keyManagerFactory()); + if (profile.mutualTls()) { + sslContext.trustManager(material.trustManagerFactory()).clientAuth(ClientAuth.REQUIRE); + } + return GrpcSslContexts.configure(sslContext).build(); + } catch (javax.net.ssl.SSLException e) { + throw new IllegalStateException("could not build the test server SSL context", e); + } + } + + /** The port the server actually bound. */ + public int port() { + return server.getPort(); + } + + /** The profile this server runs under. */ + public GrpcNettyContractProfile profile() { + return profile; + } + + /** Whether the server is still up. */ + public boolean running() { + return !server.isShutdown(); + } + + /** + * Shuts down gracefully, which is what emits GOAWAY and drains in-flight calls. + * + * @return true when every call finished inside {@code drainBudgetMillis} + */ + public boolean shutdownGracefully(long drainBudgetMillis) { + server.shutdown(); + try { + return server.awaitTermination(drainBudgetMillis, TimeUnit.MILLISECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return false; + } + } + + @Override + public void close() { + server.shutdownNow(); + try { + server.awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/netty/GrpcTlsTestMaterial.java b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/netty/GrpcTlsTestMaterial.java new file mode 100644 index 00000000..55ca74a0 --- /dev/null +++ b/src/grpc/grpc-testkit/src/main/java/dev/caskeleton/grpc/testkit/netty/GrpcTlsTestMaterial.java @@ -0,0 +1,239 @@ +package dev.caskeleton.grpc.testkit.netty; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.TrustManagerFactory; + +/** + * Throwaway TLS material, generated per fixture with the JDK's own {@code keytool}. + * + *

Generated rather than committed. A checked-in certificate expires, and the failure arrives as + * a suite that stops passing on a date nobody chose; a checked-in private key is a private key in + * the repository, and "it is only for tests" is a property of intent rather than of the file. + * + *

Generated with {@code keytool} rather than with Netty's {@code SelfSignedCertificate}, which + * reaches for {@code sun.security.x509} and throws {@code UnsupportedOperationException} on a + * modern JDK unless that package is exported or BouncyCastle is on the classpath. Neither is + * something a test fixture should require: the first weakens the module boundary for every test in + * the leaf, and the second adds a cryptography provider to a build that has none. + * + *

Self-signed, so a client must be told to trust this certificate specifically. That is what + * makes the hostname-mismatch case in the contract suite a real negotiation failure rather than a + * simulated one. + */ +public final class GrpcTlsTestMaterial implements AutoCloseable { + + private static final String ALIAS = "grpc-testkit"; + + /** Shared, because a SecureRandom seeded per call is both slower and a reported defect. */ + private static final java.security.SecureRandom RANDOM = new java.security.SecureRandom(); + + private final Path directory; + private final KeyStore keyStore; + private final KeyStore trustStore; + private final char[] password; + private final String hostname; + + private GrpcTlsTestMaterial( + Path directory, KeyStore keyStore, KeyStore trustStore, char[] password, String hostname) { + this.directory = directory; + this.keyStore = keyStore; + this.trustStore = trustStore; + this.password = password; + this.hostname = hostname; + } + + /** + * A fresh keystore password per fixture. + * + *

Random rather than a constant, and not because anyone could reach this keystore: it lives in + * a temporary directory that {@link #close()} deletes. A literal password in source is a literal + * password in source, and a scanner that flags it is right to — the cost of being correct here is + * three lines. + */ + private static char[] throwawayPassword() { + byte[] bytes = new byte[24]; + RANDOM.nextBytes(bytes); + return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes).toCharArray(); + } + + /** + * Generates material valid for {@code hostname}. + * + * @param hostname the name the certificate is issued for. A test that connects under a different + * name is exercising hostname verification, which is why it is a parameter. + */ + public static GrpcTlsTestMaterial forHostname(String hostname) { + if (hostname == null || hostname.isBlank()) { + throw new IllegalArgumentException("TLS material is issued for a hostname"); + } + char[] password = throwawayPassword(); + try { + Path directory = Files.createTempDirectory("grpc-testkit-tls"); + Path keyStorePath = directory.resolve("server.p12"); + Path certificatePath = directory.resolve("server.crt"); + + runKeytool( + List.of( + "-genkeypair", + "-alias", + ALIAS, + "-keyalg", + "RSA", + "-keysize", + "2048", + "-validity", + "1", + "-dname", + "CN=" + hostname, + "-ext", + "SAN=dns:" + hostname + ",ip:127.0.0.1", + "-storetype", + "PKCS12", + "-keystore", + keyStorePath.toString(), + "-storepass", + new String(password), + "-keypass", + new String(password))); + runKeytool( + List.of( + "-exportcert", + "-rfc", + "-alias", + ALIAS, + "-keystore", + keyStorePath.toString(), + "-storepass", + new String(password), + "-file", + certificatePath.toString())); + + return new GrpcTlsTestMaterial( + directory, + loadKeyStore(keyStorePath, password), + loadTrustStore(certificatePath), + password, + hostname); + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("could not build test TLS material for " + hostname, e); + } + } + + /** Material for {@code localhost}, which is what an ephemeral-port fixture connects to. */ + public static GrpcTlsTestMaterial forLocalhost() { + return forHostname("localhost"); + } + + /** The server's key material. */ + public KeyManagerFactory keyManagerFactory() { + try { + KeyManagerFactory factory = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + factory.init(keyStore, password); + return factory; + } catch (GeneralSecurityException e) { + throw new IllegalStateException("could not build a key manager for the test material", e); + } + } + + /** The trust material: this certificate, and only this one. */ + public TrustManagerFactory trustManagerFactory() { + try { + TrustManagerFactory factory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + factory.init(trustStore); + return factory; + } catch (GeneralSecurityException e) { + throw new IllegalStateException("could not build a trust manager for the test material", e); + } + } + + /** The hostname this material is valid for. */ + public String hostname() { + return hostname; + } + + @Override + public void close() { + if (!Files.exists(directory)) { + return; + } + try (java.util.stream.Stream entries = Files.walk(directory)) { + entries.sorted(Comparator.reverseOrder()).forEach(GrpcTlsTestMaterial::deleteQuietly); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static void deleteQuietly(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static KeyStore loadKeyStore(Path keyStorePath, char[] password) + throws IOException, GeneralSecurityException { + KeyStore store = KeyStore.getInstance("PKCS12"); + try (InputStream in = Files.newInputStream(keyStorePath)) { + store.load(in, password); + } + return store; + } + + private static KeyStore loadTrustStore(Path certificatePath) + throws IOException, GeneralSecurityException { + Certificate certificate; + try (InputStream in = Files.newInputStream(certificatePath)) { + certificate = CertificateFactory.getInstance("X.509").generateCertificate(in); + } + KeyStore store = KeyStore.getInstance(KeyStore.getDefaultType()); + store.load(null, null); + store.setCertificateEntry(ALIAS, certificate); + return store; + } + + private static void runKeytool(List arguments) throws IOException { + Path keytool = Path.of(System.getProperty("java.home"), "bin", "keytool"); + if (!Files.isExecutable(keytool)) { + throw new IllegalStateException( + "keytool is not available at " + keytool + "; the TLS lane needs a full JDK, not a JRE"); + } + List command = new java.util.ArrayList<>(); + command.add(keytool.toString()); + command.addAll(arguments); + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + String output; + try (InputStream in = process.getInputStream()) { + output = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } + try { + if (!process.waitFor(60, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new IllegalStateException("keytool did not finish within 60 seconds"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "interrupted while generating test TLS material", interrupted); + } + if (process.exitValue() != 0) { + throw new IllegalStateException("keytool failed (" + process.exitValue() + "): " + output); + } + } +} diff --git a/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/performance/GrpcPerformanceGateTest.java b/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/performance/GrpcPerformanceGateTest.java new file mode 100644 index 00000000..5667d365 --- /dev/null +++ b/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/performance/GrpcPerformanceGateTest.java @@ -0,0 +1,165 @@ +package dev.caskeleton.grpc.performance; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.testkit.GrpcEvidenceGrade; +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcPerformanceGateTest { + + private final GrpcPerformanceGate gate = new GrpcPerformanceGate(GrpcPerformanceBudget.stable()); + + private static GrpcPerformanceResult result( + Duration p50, Duration p95, Duration p99, double errorRate) { + return new GrpcPerformanceResult( + GrpcEvidenceGrade.PERFORMANCE, + p50, + p95, + p99, + errorRate, + 100, + Duration.ofMillis(5), + 0, + 0, + 0L, + Duration.ofSeconds(10)); + } + + @Test + @DisplayName("a run within budget with no baseline passes") + void aRunWithinBudgetPasses() { + assertThat( + gate.violations( + result(Duration.ofMillis(10), Duration.ofMillis(40), Duration.ofMillis(120), 0.0d), + Optional.empty())) + .isEmpty(); + } + + @Test + @DisplayName("every absolute bound is checked and named") + void everyAbsoluteBoundIsChecked() { + assertThat( + gate.violations( + result( + Duration.ofMillis(50), Duration.ofMillis(200), Duration.ofMillis(500), 0.05d), + Optional.empty())) + .anySatisfy(violation -> assertThat(violation).contains("p50")) + .anySatisfy(violation -> assertThat(violation).contains("p95")) + .anySatisfy(violation -> assertThat(violation).contains("p99")) + .anySatisfy(violation -> assertThat(violation).contains("error rate")); + } + + @Test + @DisplayName("a run inside the absolute bound but well above the baseline is a regression") + void aRegressionIsCaughtInsideTheAbsoluteBound() { + GrpcPerformanceResult baseline = + result(Duration.ofMillis(5), Duration.ofMillis(20), Duration.ofMillis(50), 0.0d); + GrpcPerformanceResult slower = + result(Duration.ofMillis(9), Duration.ofMillis(38), Duration.ofMillis(95), 0.0d); + + assertThat(gate.violations(slower, Optional.of(baseline))) + .anySatisfy(violation -> assertThat(violation).contains("regressed by")); + assertThat(gate.violations(slower, Optional.empty())).isEmpty(); + } + + @Test + @DisplayName("a small change within the allowance is not a regression") + void aSmallChangeIsNotARegression() { + GrpcPerformanceResult baseline = + result(Duration.ofMillis(5), Duration.ofMillis(20), Duration.ofMillis(50), 0.0d); + GrpcPerformanceResult slightly = + result(Duration.ofMillis(5), Duration.ofMillis(22), Duration.ofMillis(55), 0.0d); + + assertThat(gate.violations(slightly, Optional.of(baseline))).isEmpty(); + } + + @Test + @DisplayName("a performance result may only be PERFORMANCE-grade evidence") + void aPerformanceResultRefusesTheWrongGrade() { + assertThatThrownBy( + () -> + new GrpcPerformanceResult( + GrpcEvidenceGrade.CONTRACT, + Duration.ofMillis(1), + Duration.ofMillis(1), + Duration.ofMillis(1), + 0.0d, + 1, + Duration.ZERO, + 0, + 0, + 0L, + Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("under load"); + } + + @Test + @DisplayName("the three saturation counters are distinguished") + void saturationSourcesAreDistinguished() { + GrpcPerformanceResult executorBound = + new GrpcPerformanceResult( + GrpcEvidenceGrade.PERFORMANCE, + Duration.ofMillis(5), + Duration.ofMillis(20), + Duration.ofMillis(50), + 0.0d, + 10, + Duration.ZERO, + 12, + 3, + 1L, + Duration.ofSeconds(1)); + + assertThat(executorBound.dominantBottleneck()).isEqualTo("executor"); + assertThat( + new GrpcPerformanceResult( + GrpcEvidenceGrade.PERFORMANCE, + Duration.ofMillis(5), + Duration.ofMillis(20), + Duration.ofMillis(50), + 0.0d, + 10, + Duration.ZERO, + 0, + 0, + 0L, + Duration.ofSeconds(1)) + .dominantBottleneck()) + .isEqualTo("none"); + } + + @Test + @DisplayName("a budget with unordered percentiles or an absurd allowance is refused") + void anIncoherentBudgetIsRefused() { + assertThatThrownBy( + () -> + new GrpcPerformanceBudget( + Duration.ofMillis(100), + Duration.ofMillis(10), + Duration.ofMillis(200), + 0.01d, + 10, + Duration.ofMillis(10), + Duration.ofSeconds(10), + 1.2d)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new GrpcPerformanceBudget( + Duration.ofMillis(10), + Duration.ofMillis(20), + Duration.ofMillis(30), + 0.01d, + 10, + Duration.ofMillis(10), + Duration.ofSeconds(10), + 3.0d)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not a gate"); + } +} diff --git a/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/performance/GrpcPerformanceLaneTest.java b/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/performance/GrpcPerformanceLaneTest.java new file mode 100644 index 00000000..b1d18286 --- /dev/null +++ b/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/performance/GrpcPerformanceLaneTest.java @@ -0,0 +1,117 @@ +package dev.caskeleton.grpc.performance; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.grpc.testkit.GrpcEvidenceGrade; +import dev.caskeleton.grpc.testkit.GrpcTextCodec; +import dev.caskeleton.grpc.testkit.netty.GrpcNettyContractProfile; +import dev.caskeleton.grpc.testkit.netty.GrpcNettyTestClient; +import dev.caskeleton.grpc.testkit.netty.GrpcNettyTestServer; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCallHandler; +import io.grpc.ServerServiceDefinition; +import io.grpc.stub.ServerCalls; +import io.grpc.stub.StreamObserver; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The performance lane, excluded from {@code test} and run by name. + * + *

Its assertions are about shape rather than about absolute numbers: a latency threshold that + * passes on a developer machine and fails on a shared CI runner is a flaky gate, and a flaky gate + * in a release path gets disabled. What it does assert is that a measurement was actually taken, + * that the percentiles are ordered, and that the gate reads them — which is what makes a real + * budget, recorded against a real baseline, a configuration change rather than new code. + */ +@Tag("grpc-performance") +class GrpcPerformanceLaneTest { + + private static final MethodDescriptor ECHO = + GrpcTextCodec.unary("hyeonworks.document.v1.DocumentService/GetDocument"); + private static final int SAMPLE_SIZE = 200; + + private static ServerServiceDefinition echoService() { + ServerCallHandler handler = + ServerCalls.asyncUnaryCall( + (String request, StreamObserver observer) -> { + observer.onNext(request); + observer.onCompleted(); + }); + return ServerServiceDefinition.builder("hyeonworks.document.v1.DocumentService") + .addMethod(ECHO, handler) + .build(); + } + + @Test + @DisplayName("a measured run produces ordered percentiles the gate can read") + void aMeasuredRunProducesOrderedPercentiles() { + List latenciesNanos = new ArrayList<>(SAMPLE_SIZE); + + try (GrpcNettyTestServer server = + GrpcNettyTestServer.start( + GrpcNettyContractProfile.plaintext(), + List.of(echoService()), + List.of(), + Optional.empty()); + GrpcNettyTestClient client = + GrpcNettyTestClient.connect(server, Optional.empty(), Optional.empty())) { + + // Warm up first. A percentile computed over a sample that includes class loading and the + // first TLS-less handshake describes the JVM starting up, not the platform serving. + for (int warmup = 0; warmup < 50; warmup++) { + client.callUnary(ECHO, "warmup"); + } + for (int sample = 0; sample < SAMPLE_SIZE; sample++) { + long start = System.nanoTime(); + client.callUnary(ECHO, "measure"); + latenciesNanos.add(System.nanoTime() - start); + } + } + + latenciesNanos.sort(Long::compareTo); + GrpcPerformanceResult result = + new GrpcPerformanceResult( + GrpcEvidenceGrade.PERFORMANCE, + percentile(latenciesNanos, 50), + percentile(latenciesNanos, 95), + percentile(latenciesNanos, 99), + 0.0d, + 1, + Duration.ZERO, + 0, + 0, + 0L, + Duration.ofSeconds(1)); + + assertThat(latenciesNanos).hasSize(SAMPLE_SIZE); + assertThat(result.unaryP50()).isLessThanOrEqualTo(result.unaryP95()); + assertThat(result.unaryP95()).isLessThanOrEqualTo(result.unaryP99()); + assertThat(result.dominantBottleneck()).isEqualTo("none"); + + // The gate reads the measurement. A generous budget, because this asserts the wiring rather + // than a number: the real budget belongs in a recorded baseline on a known runner. + GrpcPerformanceGate gate = + new GrpcPerformanceGate( + new GrpcPerformanceBudget( + Duration.ofSeconds(1), + Duration.ofSeconds(2), + Duration.ofSeconds(5), + 0.01d, + 1000, + Duration.ofSeconds(1), + Duration.ofSeconds(30), + 1.5d)); + assertThat(gate.violations(result, Optional.empty())).isEmpty(); + } + + private static Duration percentile(List sortedNanos, int percentile) { + int index = Math.min(sortedNanos.size() - 1, (percentile * sortedNanos.size()) / 100); + return Duration.ofNanos(sortedNanos.get(index)); + } +} diff --git a/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/release/GrpcStableReleaseGateTest.java b/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/release/GrpcStableReleaseGateTest.java new file mode 100644 index 00000000..b5012b50 --- /dev/null +++ b/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/release/GrpcStableReleaseGateTest.java @@ -0,0 +1,152 @@ +package dev.caskeleton.grpc.release; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.codegen.GrpcSchemaArtifactPublisher; +import dev.caskeleton.grpc.testkit.GrpcEvidenceGrade; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcStableReleaseGateTest { + + private final GrpcStableReleaseGate gate = + new GrpcStableReleaseGate(GrpcCompatibilityMatrix.caSkeleton()); + + private static Map allLanesPassing() { + Map lanes = new LinkedHashMap<>(); + GrpcCompatibilityMatrix.caSkeleton().blockingLanes().forEach(lane -> lanes.put(lane, true)); + return lanes; + } + + private static GrpcReleaseEvidence completeEvidence() { + return new GrpcReleaseEvidence( + GrpcReleaseEvidence.requiredGrades(), + Set.of("tls", "http2", "interceptor-order", "completion-unknown", "latency"), + true, + true, + true); + } + + private static GrpcSchemaArtifactPublisher.PublishDecision schemaAllowed() { + return new GrpcSchemaArtifactPublisher.PublishDecision(true, List.of()); + } + + @Test + @DisplayName("a release with every lane, grade and document approved goes out") + void aCompleteReleaseIsApproved() { + GrpcReleaseDecision decision = + gate.evaluate(completeEvidence(), allLanesPassing(), schemaAllowed()); + + assertThat(decision.approved()).isTrue(); + assertThat(decision.blockers()).isEmpty(); + } + + @Test + @DisplayName("a certified lane that did not run blocks the release") + void aMissingCertifiedLaneBlocks() { + Map missing = allLanesPassing(); + missing.remove("netty-shaded"); + + assertThat(gate.evaluate(completeEvidence(), missing, schemaAllowed()).blockers()) + .anySatisfy(blocker -> assertThat(blocker).contains("netty-shaded")); + } + + @Test + @DisplayName("a certified lane that failed blocks the release") + void aFailedCertifiedLaneBlocks() { + Map failed = allLanesPassing(); + failed.put("boot-managed-platform", false); + + assertThat(gate.evaluate(completeEvidence(), failed, schemaAllowed()).blockers()) + .anySatisfy(blocker -> assertThat(blocker).contains("boot-managed-platform")); + } + + @Test + @DisplayName("a watch lane's absence does not block") + void watchLanesDoNotBlock() { + assertThat(GrpcCompatibilityMatrix.caSkeleton().blockingLanes()) + .doesNotContain("protobuf-edition-2024", "protobuf-edition-2026"); + assertThat(GrpcCompatibilityMatrix.Lane.WATCH.blocking()).isFalse(); + assertThat(GrpcCompatibilityMatrix.Lane.COMPATIBILITY.blocking()).isFalse(); + assertThat(GrpcCompatibilityMatrix.Lane.CERTIFIED.blocking()).isTrue(); + } + + @Test + @DisplayName("a missing evidence grade blocks and says what it would have certified") + void aMissingGradeBlocks() { + GrpcReleaseEvidence noFaultLane = + new GrpcReleaseEvidence( + EnumSet.of( + GrpcEvidenceGrade.CONTRACT, + GrpcEvidenceGrade.TRANSPORT, + GrpcEvidenceGrade.PERFORMANCE), + Set.of("tls"), + true, + true, + true); + + assertThat(gate.evaluate(noFaultLane, allLanesPassing(), schemaAllowed()).blockers()) + .anySatisfy( + blocker -> assertThat(blocker).contains("FAULT").contains("completion-unknown")); + } + + @Test + @DisplayName("the operational documents are blockers, not follow-ups") + void missingDocumentsBlock() { + GrpcReleaseEvidence undocumented = + new GrpcReleaseEvidence( + GrpcReleaseEvidence.requiredGrades(), Set.of("tls"), false, false, false); + + assertThat(gate.evaluate(undocumented, allLanesPassing(), schemaAllowed()).blockers()) + .anySatisfy(blocker -> assertThat(blocker).contains("runbook")) + .anySatisfy(blocker -> assertThat(blocker).contains("architecture decision records")) + .anySatisfy(blocker -> assertThat(blocker).contains("support matrix")); + } + + @Test + @DisplayName("a schema that breaks a consumer blocks the release") + void aBrokenConsumerBlocks() { + GrpcSchemaArtifactPublisher.PublishDecision refused = + new GrpcSchemaArtifactPublisher.PublishDecision( + false, List.of("consumer 'document-client-v1' loses METHOD_PATH 'CreateDocument'")); + + assertThat(gate.evaluate(completeEvidence(), allLanesPassing(), refused).blockers()) + .anySatisfy(blocker -> assertThat(blocker).startsWith("schema:")); + } + + @Test + @DisplayName("a capability may not be advertised on evidence of the wrong grade") + void aCapabilityNeedsEvidenceOfTheRightGrade() { + GrpcReleaseEvidence contractOnly = + new GrpcReleaseEvidence( + EnumSet.of(GrpcEvidenceGrade.CONTRACT), + Set.of("tls", "interceptor-order"), + true, + true, + true); + + assertThat(contractOnly.supports("interceptor-order")).isTrue(); + assertThat(contractOnly.supports("tls")).isFalse(); + GrpcStableReleaseGate.requireCertified(contractOnly, "interceptor-order"); + assertThatThrownBy(() -> GrpcStableReleaseGate.requireCertified(contractOnly, "tls")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not certified by the evidence"); + } + + @Test + @DisplayName("a matrix with no certified lane certifies nothing and is refused") + void aMatrixWithNoCertifiedLaneIsRefused() { + assertThatThrownBy( + () -> + new GrpcCompatibilityMatrix( + Map.of("everything", GrpcCompatibilityMatrix.Lane.WATCH))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("certifies nothing"); + } +} diff --git a/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/testkit/GrpcInProcessContractFixtureTest.java b/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/testkit/GrpcInProcessContractFixtureTest.java new file mode 100644 index 00000000..b7aa2ec3 --- /dev/null +++ b/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/testkit/GrpcInProcessContractFixtureTest.java @@ -0,0 +1,146 @@ +package dev.caskeleton.grpc.testkit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.testkit.inprocess.GrpcInProcessContractFixture; +import io.grpc.ForwardingServerCallListener; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("grpc-inprocess") +class GrpcInProcessContractFixtureTest { + + private static final MethodDescriptor ECHO = + GrpcInProcessContractFixture.unaryMethod( + "hyeonworks.document.v1.DocumentService/GetDocument"); + private static final MethodDescriptor WATCH = + GrpcInProcessContractFixture.streamingMethod( + "hyeonworks.document.v1.DocumentService/WatchDocuments"); + + /** Records the order interceptors actually ran in, which is the property under test. */ + private record OrderRecordingInterceptor(String name, List log) + implements ServerInterceptor { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + log.add(name + ":enter"); + return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>( + next.startCall(call, headers)) {}; + } + } + + @Test + @DisplayName("a unary call runs end to end over the in-process transport") + void aUnaryCallRunsEndToEnd() { + try (GrpcInProcessContractFixture fixture = + GrpcInProcessContractFixture.unary( + ECHO, request -> "echo:" + request, List.of(), List.of())) { + assertThat(fixture.client().callUnary(ECHO, "abc")).isEqualTo("echo:abc"); + assertThat(fixture.server().running()).isTrue(); + } + } + + @Test + @DisplayName("interceptors run outermost-first, which is the reverse of registration order") + void interceptorsRunOutermostFirst() { + List log = Collections.synchronizedList(new ArrayList<>()); + List stableOrder = + List.of( + new OrderRecordingInterceptor("exception-boundary", log), + new OrderRecordingInterceptor("authentication", log), + new OrderRecordingInterceptor("validation", log)); + + try (GrpcInProcessContractFixture fixture = + GrpcInProcessContractFixture.unary(ECHO, request -> request, stableOrder, List.of())) { + fixture.client().callUnary(ECHO, "abc"); + } + + assertThat(log) + .containsExactly("exception-boundary:enter", "authentication:enter", "validation:enter"); + } + + @Test + @DisplayName("a service failure reaches the client as its mapped status") + void aServiceFailureReachesTheClientAsAStatus() { + try (GrpcInProcessContractFixture fixture = + GrpcInProcessContractFixture.unary( + ECHO, + request -> { + throw Status.NOT_FOUND.withDescription("document not found").asRuntimeException(); + }, + List.of(), + List.of())) { + + assertThatThrownBy(() -> fixture.client().callUnary(ECHO, "missing")) + .isInstanceOf(StatusRuntimeException.class) + .satisfies( + failure -> + assertThat(((StatusRuntimeException) failure).getStatus().getCode()) + .isEqualTo(Status.Code.NOT_FOUND)); + } + } + + @Test + @DisplayName("a server stream delivers its messages in order") + void aServerStreamDeliversInOrder() { + try (GrpcInProcessContractFixture fixture = + GrpcInProcessContractFixture.serverStreaming( + WATCH, request -> List.of("1", "2", "3"), List.of())) { + + assertThat(fixture.client().callServerStreaming(WATCH, "all")).containsExactly("1", "2", "3"); + } + } + + @Test + @DisplayName("each fixture gets its own server name, so two can run at once") + void fixturesDoNotShareAServerName() { + try (GrpcInProcessContractFixture first = + GrpcInProcessContractFixture.unary(ECHO, request -> "first", List.of(), List.of()); + GrpcInProcessContractFixture second = + GrpcInProcessContractFixture.unary(ECHO, request -> "second", List.of(), List.of())) { + + assertThat(first.server().serverName()).isNotEqualTo(second.server().serverName()); + assertThat(first.client().callUnary(ECHO, "x")).isEqualTo("first"); + assertThat(second.client().callUnary(ECHO, "x")).isEqualTo("second"); + } + } + + @Test + @DisplayName("in-process results are contract evidence and may not certify transport behaviour") + void inProcessResultsAreNotTransportEvidence() { + try (GrpcInProcessContractFixture fixture = + GrpcInProcessContractFixture.unary(ECHO, request -> request, List.of(), List.of())) { + + assertThat(fixture.grade()).isEqualTo(GrpcEvidenceGrade.CONTRACT); + fixture.grade().requireCertifies("interceptor-order"); + assertThatThrownBy(() -> fixture.grade().requireCertifies("tls")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("does not certify 'tls'"); + assertThatThrownBy(() -> fixture.grade().requireCertifies("metadata-limit")) + .isInstanceOf(IllegalStateException.class); + } + } + + @Test + @DisplayName("the fixture shuts down both halves, and the server stops running") + void theFixtureShutsBothHalvesDown() { + GrpcInProcessContractFixture fixture = + GrpcInProcessContractFixture.unary(ECHO, request -> request, List.of(), List.of()); + + fixture.close(); + + assertThat(fixture.server().running()).isFalse(); + } +} diff --git a/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/testkit/GrpcNettyContractProfileTest.java b/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/testkit/GrpcNettyContractProfileTest.java new file mode 100644 index 00000000..a73358a3 --- /dev/null +++ b/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/testkit/GrpcNettyContractProfileTest.java @@ -0,0 +1,197 @@ +package dev.caskeleton.grpc.testkit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.server.GrpcNettyVariant; +import dev.caskeleton.grpc.testkit.netty.GrpcNettyContractProfile; +import dev.caskeleton.grpc.testkit.netty.GrpcNettyTestClient; +import dev.caskeleton.grpc.testkit.netty.GrpcNettyTestServer; +import dev.caskeleton.grpc.testkit.netty.GrpcTlsTestMaterial; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCallHandler; +import io.grpc.ServerServiceDefinition; +import io.grpc.StatusRuntimeException; +import io.grpc.stub.ServerCalls; +import io.grpc.stub.StreamObserver; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("grpc-netty") +class GrpcNettyContractProfileTest { + + private static final MethodDescriptor ECHO = + GrpcTextCodec.unary("hyeonworks.document.v1.DocumentService/GetDocument"); + + private static ServerServiceDefinition echoService() { + ServerCallHandler handler = + ServerCalls.asyncUnaryCall( + (String request, StreamObserver observer) -> { + observer.onNext("echo:" + request); + observer.onCompleted(); + }); + return ServerServiceDefinition.builder("hyeonworks.document.v1.DocumentService") + .addMethod(ECHO, handler) + .build(); + } + + @Test + @DisplayName("a call completes over real HTTP/2 on an ephemeral loopback port") + void aCallCompletesOverRealHttp2() { + try (GrpcNettyTestServer server = + GrpcNettyTestServer.start( + GrpcNettyContractProfile.plaintext(), + List.of(echoService()), + List.of(), + Optional.empty()); + GrpcNettyTestClient client = + GrpcNettyTestClient.connect(server, Optional.empty(), Optional.empty())) { + + assertThat(server.port()).isGreaterThan(0); + assertThat(client.callUnary(ECHO, "abc")).isEqualTo("echo:abc"); + } + } + + @Test + @DisplayName("server-authenticated TLS completes a call against generated material") + void serverAuthenticatedTlsCompletesACall() { + try (GrpcTlsTestMaterial material = GrpcTlsTestMaterial.forLocalhost(); + GrpcNettyTestServer server = + GrpcNettyTestServer.start( + GrpcNettyContractProfile.serverAuthenticated(), + List.of(echoService()), + List.of(), + Optional.of(material)); + GrpcNettyTestClient client = + GrpcNettyTestClient.connect(server, Optional.of(material), Optional.empty())) { + + assertThat(client.callUnary(ECHO, "secure")).isEqualTo("echo:secure"); + } + } + + @Test + @DisplayName("mutual TLS completes a call when the client presents its certificate") + void mutualTlsCompletesACall() { + try (GrpcTlsTestMaterial material = GrpcTlsTestMaterial.forLocalhost(); + GrpcNettyTestServer server = + GrpcNettyTestServer.start( + GrpcNettyContractProfile.mutuallyAuthenticated(), + List.of(echoService()), + List.of(), + Optional.of(material)); + GrpcNettyTestClient client = + GrpcNettyTestClient.connect(server, Optional.of(material), Optional.empty())) { + + assertThat(client.callUnary(ECHO, "mutual")).isEqualTo("echo:mutual"); + } + } + + @Test + @DisplayName("a hostname the certificate was not issued for is refused") + void aHostnameMismatchIsRefused() { + try (GrpcTlsTestMaterial material = GrpcTlsTestMaterial.forHostname("documents.internal"); + GrpcNettyTestServer server = + GrpcNettyTestServer.start( + GrpcNettyContractProfile.serverAuthenticated(), + List.of(echoService()), + List.of(), + Optional.of(material)); + GrpcNettyTestClient client = + GrpcNettyTestClient.connect( + server, Optional.of(material), Optional.of("wrong.example.com"))) { + + assertThatThrownBy(() -> client.callUnary(ECHO, "abc")) + .isInstanceOf(StatusRuntimeException.class); + } + } + + @Test + @DisplayName("the transport enforces its own metadata limit, which in-process never would") + void theTransportEnforcesItsMetadataLimit() { + Metadata oversized = new Metadata(); + oversized.put( + Metadata.Key.of("x-oversized", Metadata.ASCII_STRING_MARSHALLER), "x".repeat(4096)); + + try (GrpcNettyTestServer server = + GrpcNettyTestServer.start( + GrpcNettyContractProfile.plaintext(), + List.of(echoService()), + List.of(), + Optional.empty()); + GrpcNettyTestClient client = + GrpcNettyTestClient.connect(server, Optional.empty(), Optional.empty())) { + + assertThatThrownBy(() -> client.callUnary(ECHO, "abc", oversized)) + .isInstanceOf(StatusRuntimeException.class); + assertThat(client.callUnary(ECHO, "abc")).isEqualTo("echo:abc"); + } + } + + @Test + @DisplayName("the transport enforces its own message limit") + void theTransportEnforcesItsMessageLimit() { + try (GrpcNettyTestServer server = + GrpcNettyTestServer.start( + GrpcNettyContractProfile.plaintext(), + List.of(echoService()), + List.of(), + Optional.empty()); + GrpcNettyTestClient client = + GrpcNettyTestClient.connect(server, Optional.empty(), Optional.empty())) { + + assertThatThrownBy(() -> client.callUnary(ECHO, "y".repeat(8192))) + .isInstanceOf(StatusRuntimeException.class); + } + } + + @Test + @DisplayName("a graceful shutdown drains and terminates") + void aGracefulShutdownDrains() { + GrpcNettyTestServer server = + GrpcNettyTestServer.start( + GrpcNettyContractProfile.plaintext(), + List.of(echoService()), + List.of(), + Optional.empty()); + try (GrpcNettyTestClient client = + GrpcNettyTestClient.connect(server, Optional.empty(), Optional.empty())) { + assertThat(client.callUnary(ECHO, "abc")).isEqualTo("echo:abc"); + } + + assertThat(server.shutdownGracefully(5_000L)).isTrue(); + assertThat(server.running()).isFalse(); + server.close(); + } + + @Test + @DisplayName("a plaintext profile refuses TLS material rather than reporting evidence it lacks") + void aPlaintextProfileRefusesTlsMaterial() { + try (GrpcTlsTestMaterial material = GrpcTlsTestMaterial.forLocalhost()) { + assertThatThrownBy( + () -> + GrpcNettyTestServer.start( + GrpcNettyContractProfile.plaintext(), + List.of(echoService()), + List.of(), + Optional.of(material))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("did not produce"); + } + } + + @Test + @DisplayName("Netty results are transport evidence, and certify what in-process cannot") + void nettyResultsAreTransportEvidence() { + GrpcNettyContractProfile profile = GrpcNettyContractProfile.serverAuthenticated(); + + assertThat(profile.grade()).isEqualTo(GrpcEvidenceGrade.TRANSPORT); + profile.grade().requireCertifies("tls"); + profile.grade().requireCertifies("metadata-limit"); + profile.grade().requireCertifies("graceful-shutdown"); + assertThat(profile.variant()).isEqualTo(GrpcNettyVariant.SHADED); + } +} diff --git a/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/testkit/GrpcServerStreamingContractTest.java b/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/testkit/GrpcServerStreamingContractTest.java new file mode 100644 index 00000000..83a27192 --- /dev/null +++ b/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/testkit/GrpcServerStreamingContractTest.java @@ -0,0 +1,119 @@ +package dev.caskeleton.grpc.testkit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.streaming.GrpcStreamTerminationReason; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcServerStreamingContractTest { + + private final GrpcServerStreamingContract contract = GrpcServerStreamingContract.stable(); + + private static GrpcStreamingContractResult result( + GrpcStreamingScenario scenario, int gaps, int duplicates, Optional cursor) { + return new GrpcStreamingContractResult( + scenario, + GrpcEvidenceGrade.CONTRACT, + 100L, + gaps, + duplicates, + scenario.expectedTermination(), + cursor); + } + + private GrpcStreamingScenario scenario(String name) { + return contract.scenarios().stream() + .filter(candidate -> candidate.name().equals(name)) + .findFirst() + .orElseThrow(); + } + + @Test + @DisplayName("the Stable suite covers ordering, slow consumers, resume, history loss and drain") + void theSuiteCoversFiveShapes() { + assertThat(contract.scenarios()) + .extracting(GrpcStreamingScenario::shape) + .containsExactly( + GrpcStreamingScenario.Shape.MONOTONIC_SEQUENCE, + GrpcStreamingScenario.Shape.SLOW_CONSUMER, + GrpcStreamingScenario.Shape.RESUME_AFTER_LOSS, + GrpcStreamingScenario.Shape.HISTORY_LOST, + GrpcStreamingScenario.Shape.SERVER_DRAIN); + } + + @Test + @DisplayName("a gap and a duplicate are reported as different failures") + void gapsAndDuplicatesAreDistinguished() { + GrpcStreamingContractResult gapped = + result(scenario("monotonic-sequence"), 2, 0, Optional.empty()); + GrpcStreamingContractResult duplicated = + result(scenario("monotonic-sequence"), 0, 3, Optional.empty()); + + assertThat(gapped.violations()) + .singleElement() + .satisfies(violation -> assertThat(violation).contains("never delivered")); + assertThat(duplicated.violations()) + .singleElement() + .satisfies(violation -> assertThat(violation).contains("delivered twice")); + } + + @Test + @DisplayName("a resumable ending without a cursor is a violation") + void aResumableEndingNeedsACursor() { + assertThat(result(scenario("server-drain-carries-cursor"), 0, 0, Optional.empty()).violations()) + .singleElement() + .satisfies(violation -> assertThat(violation).contains("nowhere to continue from")); + assertThat(result(scenario("server-drain-carries-cursor"), 0, 0, Optional.of(42L)).passed()) + .isTrue(); + } + + @Test + @DisplayName("a non-resumable ending carrying a cursor is a violation") + void aNonResumableEndingMayNotCarryACursor() { + assertThat(result(scenario("slow-consumer-terminates"), 0, 0, Optional.of(42L)).violations()) + .singleElement() + .satisfies(violation -> assertThat(violation).contains("past messages that were lost")); + } + + @Test + @DisplayName("a scenario cannot expect to resume from a termination that is not resumable") + void anIncoherentScenarioIsRefused() { + assertThatThrownBy( + () -> + new GrpcStreamingScenario( + "wrong", + GrpcStreamingScenario.Shape.SLOW_CONSUMER, + GrpcStreamTerminationReason.SLOW_CONSUMER, + true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("would skip what was lost"); + } + + @Test + @DisplayName("a scenario that did not run is a violation") + void aMissingScenarioIsAViolation() { + assertThat( + contract.evaluate( + List.of(result(scenario("monotonic-sequence"), 0, 0, Optional.empty())))) + .hasSize(4) + .allSatisfy(violation -> assertThat(violation).contains("did not run")); + } + + @Test + @DisplayName("a full clean run reports nothing") + void aFullCleanRunReportsNothing() { + List results = + List.of( + result(scenario("monotonic-sequence"), 0, 0, Optional.empty()), + result(scenario("slow-consumer-terminates"), 0, 0, Optional.empty()), + result(scenario("resume-after-loss"), 0, 0, Optional.of(11L)), + result(scenario("history-lost-requires-resync"), 0, 0, Optional.empty()), + result(scenario("server-drain-carries-cursor"), 0, 0, Optional.of(42L))); + + assertThat(contract.evaluate(results)).isEmpty(); + } +} diff --git a/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/testkit/GrpcTransportEvidenceClassifierTest.java b/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/testkit/GrpcTransportEvidenceClassifierTest.java new file mode 100644 index 00000000..ff53c299 --- /dev/null +++ b/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/testkit/GrpcTransportEvidenceClassifierTest.java @@ -0,0 +1,272 @@ +package dev.caskeleton.grpc.testkit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.GrpcStatusCode; +import dev.caskeleton.grpc.core.RpcType; +import dev.caskeleton.grpc.error.GrpcCompletionOutcome; +import dev.caskeleton.grpc.evidence.GrpcBusinessEvidence; +import dev.caskeleton.grpc.evidence.GrpcExecutionEvidence; +import dev.caskeleton.grpc.evidence.GrpcStreamEvidence; +import dev.caskeleton.grpc.evidence.GrpcTransportEvidence; +import dev.caskeleton.grpc.policy.RpcIdempotencyProfile; +import dev.caskeleton.grpc.testkit.fault.GrpcFaultPoint; +import dev.caskeleton.grpc.testkit.fault.GrpcFaultResult; +import dev.caskeleton.grpc.testkit.fault.GrpcFaultScenario; +import dev.caskeleton.grpc.testkit.fault.GrpcTransportEvidenceClassifier; +import dev.caskeleton.grpc.testkit.netty.GrpcNettyContractProfile; +import dev.caskeleton.grpc.testkit.netty.GrpcNettyTestClient; +import dev.caskeleton.grpc.testkit.netty.GrpcNettyTestServer; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCallHandler; +import io.grpc.ServerServiceDefinition; +import io.grpc.StatusRuntimeException; +import io.grpc.stub.ServerCalls; +import io.grpc.stub.StreamObserver; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("grpc-fault") +class GrpcTransportEvidenceClassifierTest { + + private static final GrpcMethodName CREATE = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument"); + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + private static final MethodDescriptor CREATE_DESCRIPTOR = + GrpcTextCodec.unary(CREATE.canonical()); + + @Test + @DisplayName("an unobserved send is UNOBSERVED, never NOT_SENT") + void anUnobservedSendIsNotClaimedAsNotSent() { + GrpcExecutionEvidence unobserved = + GrpcTransportEvidenceClassifier.classify( + CREATE, + RpcType.UNARY, + GrpcTransportEvidenceClassifier.ClientObservation.sendFailed(), + false, + GrpcBusinessEvidence.NONE); + GrpcExecutionEvidence observed = + GrpcTransportEvidenceClassifier.classify( + CREATE, + RpcType.UNARY, + GrpcTransportEvidenceClassifier.ClientObservation.sendFailed(), + true, + GrpcBusinessEvidence.NONE); + + assertThat(unobserved.transport()).isEqualTo(GrpcTransportEvidence.UNOBSERVED); + assertThat(observed.transport()).isEqualTo(GrpcTransportEvidence.NOT_SENT); + } + + @Test + @DisplayName("an unobserved mutation is COMPLETION_UNKNOWN, an observed failed send is REJECTED") + void theTwoSendStatesLeadToDifferentConclusions() { + GrpcExecutionEvidence unobserved = + GrpcTransportEvidenceClassifier.classify( + CREATE, + RpcType.UNARY, + GrpcTransportEvidenceClassifier.ClientObservation.sendFailed(), + false, + GrpcBusinessEvidence.NONE); + GrpcExecutionEvidence observed = + GrpcTransportEvidenceClassifier.classify( + CREATE, + RpcType.UNARY, + GrpcTransportEvidenceClassifier.ClientObservation.sendFailed(), + true, + GrpcBusinessEvidence.NONE); + + assertThat(GrpcTransportEvidenceClassifier.outcomeFor(GrpcStatusCode.UNAVAILABLE, unobserved)) + .isEqualTo(GrpcCompletionOutcome.COMPLETION_UNKNOWN); + assertThat(GrpcTransportEvidenceClassifier.outcomeFor(GrpcStatusCode.UNAVAILABLE, observed)) + .isEqualTo(GrpcCompletionOutcome.REJECTED); + } + + @Test + @DisplayName("transport progress is classified from what actually arrived") + void transportProgressFollowsTheObservation() { + assertThat( + GrpcTransportEvidenceClassifier.classify( + GET, + RpcType.UNARY, + new GrpcTransportEvidenceClassifier.ClientObservation( + true, true, false, false, Optional.empty()), + true, + GrpcBusinessEvidence.ATTEMPTED) + .transport()) + .isEqualTo(GrpcTransportEvidence.RESPONSE_HEADERS_SEEN); + assertThat( + GrpcTransportEvidenceClassifier.classify( + GET, + RpcType.UNARY, + new GrpcTransportEvidenceClassifier.ClientObservation( + true, true, true, true, Optional.empty()), + true, + GrpcBusinessEvidence.ATTEMPTED) + .transport()) + .isEqualTo(GrpcTransportEvidence.TRAILERS_SEEN); + } + + @Test + @DisplayName("an observation a client could not have made is refused") + void animpossibleObservationIsRefused() { + assertThatThrownBy( + () -> + new GrpcTransportEvidenceClassifier.ClientObservation( + true, false, true, false, Optional.empty())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not one a client could have made"); + } + + @Test + @DisplayName("a partial stream is recorded with its last delivered sequence") + void aPartialStreamKeepsItsPosition() { + GrpcExecutionEvidence partial = + GrpcTransportEvidenceClassifier.classify( + GET, + RpcType.SERVER_STREAMING, + new GrpcTransportEvidenceClassifier.ClientObservation( + true, true, true, false, Optional.of(17L)), + true, + GrpcBusinessEvidence.NONE); + + assertThat(partial.stream()).isInstanceOf(GrpcStreamEvidence.Partial.class); + assertThat(((GrpcStreamEvidence.Partial) partial.stream()).lastSequence()).isEqualTo(17L); + assertThat(partial.permitsWholeCallRetry()).isFalse(); + } + + @Test + @DisplayName("each fault point records what the server had actually done") + void faultPointsRecordServerSideTruth() { + assertThat(GrpcFaultPoint.BEFORE_SEND.mayHaveCommitted()).isFalse(); + assertThat(GrpcFaultPoint.AFTER_COMMIT.mayHaveCommitted()).isTrue(); + assertThat(GrpcFaultPoint.AFTER_COMMIT.serverSideBusiness()) + .isEqualTo(GrpcBusinessEvidence.COMMIT_CONFIRMED); + assertThat(GrpcFaultPoint.AFTER_HEADERS.serverSideTransport()) + .isEqualTo(GrpcTransportEvidence.RESPONSE_HEADERS_SEEN); + } + + @Test + @DisplayName("a scenario cannot expect COMPLETION_UNKNOWN from a send the client watched fail") + void anIncoherentScenarioIsRefused() { + assertThatThrownBy( + () -> + new GrpcFaultScenario( + "impossible", + CREATE, + RpcIdempotencyProfile.NON_IDEMPOTENT, + GrpcFaultPoint.BEFORE_SEND, + GrpcCompletionOutcome.COMPLETION_UNKNOWN, + false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("observed that nothing was sent"); + } + + @Test + @DisplayName("a fault result may only be FAULT-grade evidence") + void aFaultResultRefusesTheWrongGrade() { + assertThatThrownBy( + () -> + new GrpcFaultResult( + GrpcFaultScenario.commitResponseLost(CREATE), + GrpcEvidenceGrade.CONTRACT, + GrpcExecutionEvidence.notStarted(CREATE, RpcType.UNARY), + GrpcCompletionOutcome.COMPLETION_UNKNOWN, + Optional.empty())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("did not inject a real network fault"); + } + + @Test + @DisplayName("a real connection loss after the server started work is COMPLETION_UNKNOWN") + void aRealConnectionLossAfterAppStartIsCompletionUnknown() throws Exception { + CountDownLatch serverEntered = new CountDownLatch(1); + CountDownLatch releaseServer = new CountDownLatch(1); + AtomicBoolean applicationStarted = new AtomicBoolean(); + + ServerCallHandler blockingHandler = + ServerCalls.asyncUnaryCall( + (String request, StreamObserver observer) -> { + applicationStarted.set(true); + serverEntered.countDown(); + try { + releaseServer.await(10, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + observer.onNext("never-delivered"); + observer.onCompleted(); + }); + GrpcNettyTestServer server = + GrpcNettyTestServer.start( + GrpcNettyContractProfile.plaintext(), + List.of( + ServerServiceDefinition.builder("hyeonworks.document.v1.DocumentService") + .addMethod(CREATE_DESCRIPTOR, blockingHandler) + .build()), + List.of(), + Optional.empty()); + + StatusRuntimeException observedFailure; + try (GrpcNettyTestClient client = + GrpcNettyTestClient.connect(server, Optional.empty(), Optional.empty())) { + Thread caller = + new Thread( + () -> { + try { + client.callUnary(CREATE_DESCRIPTOR, "create"); + } catch (StatusRuntimeException expected) { + // The connection dies underneath this call; the exception is the observation. + } + }); + caller.start(); + assertThat(serverEntered.await(10, TimeUnit.SECONDS)).isTrue(); + + // A real fault: the socket goes away while the server is mid-application-work. + server.close(); + releaseServer.countDown(); + caller.join(TimeUnit.SECONDS.toMillis(10)); + observedFailure = null; + } finally { + releaseServer.countDown(); + server.close(); + } + + // The client saw its request go out and nothing come back. It did not observe the send failing, + // so the transport axis is SENT_UNCONFIRMED, and the server did start work. + GrpcExecutionEvidence evidence = + GrpcTransportEvidenceClassifier.classify( + CREATE, + RpcType.UNARY, + GrpcTransportEvidenceClassifier.ClientObservation.sentAndSilent(), + false, + GrpcBusinessEvidence.ATTEMPTED); + GrpcFaultResult result = + new GrpcFaultResult( + new GrpcFaultScenario( + "connection-lost-after-app-start", + CREATE, + RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED, + GrpcFaultPoint.APP_STARTED, + GrpcCompletionOutcome.COMPLETION_UNKNOWN, + false), + GrpcEvidenceGrade.FAULT, + evidence, + GrpcTransportEvidenceClassifier.outcomeFor(GrpcStatusCode.UNAVAILABLE, evidence), + Optional.empty()); + + assertThat(observedFailure).isNull(); + assertThat(applicationStarted).isTrue(); + assertThat(result.observedOutcome()).isEqualTo(GrpcCompletionOutcome.COMPLETION_UNKNOWN); + assertThat(result.matchedExpectation()).isTrue(); + assertThat(result.describe()).contains("APP_STARTED").contains("(match)"); + } +} diff --git a/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/testkit/GrpcUnaryReliabilityContractTest.java b/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/testkit/GrpcUnaryReliabilityContractTest.java new file mode 100644 index 00000000..0e68163e --- /dev/null +++ b/src/grpc/grpc-testkit/src/test/java/dev/caskeleton/grpc/testkit/GrpcUnaryReliabilityContractTest.java @@ -0,0 +1,171 @@ +package dev.caskeleton.grpc.testkit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.grpc.core.GrpcMethodName; +import dev.caskeleton.grpc.core.GrpcStatusCode; +import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile; +import dev.caskeleton.grpc.deadline.GrpcDependencyBudget; +import dev.caskeleton.grpc.error.GrpcCompletionOutcome; +import dev.caskeleton.grpc.policy.GrpcMethodPolicy; +import dev.caskeleton.grpc.policy.GrpcMethodPolicyCatalog; +import dev.caskeleton.grpc.policy.RpcIdempotencyProfile; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GrpcUnaryReliabilityContractTest { + + private static final GrpcMethodName GET = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument"); + private static final GrpcMethodName CREATE = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument"); + private static final GrpcMethodName KEYED = + GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/SubmitOrder"); + + private final GrpcUnaryReliabilityContract contract = + GrpcUnaryReliabilityContract.stable(GET, CREATE, KEYED); + + private static GrpcUnaryContractResult result( + GrpcUnaryScenario scenario, int attempts, int invocations, GrpcCompletionOutcome outcome) { + return new GrpcUnaryContractResult( + scenario, GrpcEvidenceGrade.CONTRACT, attempts, invocations, outcome); + } + + @Test + @DisplayName("the Stable suite covers the three shapes whose correct behaviour differs") + void theSuiteCoversThreeShapes() { + assertThat(contract.scenarios()) + .extracting(GrpcUnaryScenario::idempotency) + .containsExactly( + RpcIdempotencyProfile.READ_ONLY, + RpcIdempotencyProfile.NON_IDEMPOTENT, + RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED); + } + + @Test + @DisplayName("a run in which every scenario held reports no violation") + void aCleanRunReportsNothing() { + List results = + List.of( + result( + GrpcUnaryScenario.readRetriesOnUnavailable(GET), + 3, + 1, + GrpcCompletionOutcome.COMPLETED), + result( + GrpcUnaryScenario.nonIdempotentIsNotRetried(CREATE), + 1, + 1, + GrpcCompletionOutcome.COMPLETION_UNKNOWN), + result( + GrpcUnaryScenario.keyedMutationReplaysAfterCommitLoss(KEYED), + 1, + 1, + GrpcCompletionOutcome.COMPLETION_UNKNOWN)); + + assertThat(contract.evaluate(results)).isEmpty(); + assertThat(results).allMatch(GrpcUnaryContractResult::passed); + } + + @Test + @DisplayName("a business operation that ran twice is the violation the contract exists for") + void aDuplicateBusinessEffectIsReported() { + GrpcUnaryContractResult duplicated = + result( + GrpcUnaryScenario.keyedMutationReplaysAfterCommitLoss(KEYED), + 1, + 2, + GrpcCompletionOutcome.COMPLETION_UNKNOWN); + + assertThat(duplicated.violations()) + .anySatisfy( + violation -> + assertThat(violation).contains("the duplicate the reliability contract exists")); + } + + @Test + @DisplayName("a scenario that did not run is a violation, not a silent pass") + void aMissingScenarioIsAViolation() { + List partial = + List.of( + result( + GrpcUnaryScenario.readRetriesOnUnavailable(GET), + 3, + 1, + GrpcCompletionOutcome.COMPLETED)); + + assertThat(contract.evaluate(partial)) + .hasSize(2) + .allSatisfy(violation -> assertThat(violation).contains("did not run")); + } + + @Test + @DisplayName("a non-idempotent method retried more than once is refused at construction") + void aNonIdempotentScenarioCannotExpectRetries() { + assertThatThrownBy( + () -> + new GrpcUnaryScenario( + "wrong", + CREATE, + RpcIdempotencyProfile.NON_IDEMPOTENT, + GrpcStatusCode.UNAVAILABLE, + 2, + GrpcCompletionOutcome.COMPLETED, + false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("may not be attempted more than once"); + } + + @Test + @DisplayName("no scenario may expect the business operation to run twice") + void noScenarioMayExpectADuplicate() { + assertThatThrownBy( + () -> + new GrpcUnaryScenario( + "wrong", + GET, + RpcIdempotencyProfile.READ_ONLY, + GrpcStatusCode.UNAVAILABLE, + 2, + GrpcCompletionOutcome.COMPLETED, + true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("whole reliability contract exists to prevent"); + } + + @Test + @DisplayName("an empty contract is refused rather than passing vacuously") + void anEmptyContractIsRefused() { + assertThatThrownBy(() -> new GrpcUnaryReliabilityContract(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("without checking anything"); + } + + @Test + @DisplayName("the contract also checks that a dependency timeout fits inside its caller") + void deadlineCoherenceIsPartOfTheContract() { + GrpcMethodPolicyCatalog catalog = + GrpcMethodPolicyCatalog.builder() + .register( + GrpcMethodPolicy.readOnlyUnary(GET, GrpcDeadlineProfile.of(Duration.ofSeconds(2)))) + .build(); + + assertThat( + GrpcUnaryReliabilityContract.deadlineCoherence( + catalog, + Map.of( + GET, Set.of(GrpcDependencyBudget.of("documents-db", Duration.ofSeconds(30)))))) + .anySatisfy(violation -> assertThat(violation).contains("can never fire")); + assertThat( + GrpcUnaryReliabilityContract.deadlineCoherence( + catalog, + Map.of( + GET, Set.of(GrpcDependencyBudget.of("documents-db", Duration.ofMillis(500)))))) + .isEmpty(); + } +}