feat: grpc 기능 deep 구현
This commit is contained in:
@@ -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:<leaf>: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`가 거부한다).
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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=
|
||||
+71
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<String> adminNetworks, Set<String> 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<String> 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<String> forbiddenFields(Map<String, String> candidate) {
|
||||
if (candidate == null) {
|
||||
throw new IllegalArgumentException("a candidate snapshot map is required");
|
||||
}
|
||||
return candidate.keySet().stream()
|
||||
.filter(GrpcAdminExposurePolicy::secretField)
|
||||
.sorted()
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
+162
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<GrpcDrainPhase> 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.
|
||||
*
|
||||
* <p>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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.grpc.admin;
|
||||
|
||||
/**
|
||||
* The shutdown sequence, in the order it must run.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package dev.caskeleton.grpc.admin;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* The two budgets a drain runs against.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package dev.caskeleton.grpc.admin;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What a drain actually achieved.
|
||||
*
|
||||
* <p>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<GrpcDrainPhase> 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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.grpc.admin;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Which dependencies a service's health actually depends on.
|
||||
*
|
||||
* <p>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<String> 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<String> dependencies) {
|
||||
return new GrpcHealthPolicy(dependencies, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code dependencyName} being down should make this service unhealthy.
|
||||
*
|
||||
* <p>False for anything not declared critical, deliberately.
|
||||
*/
|
||||
public boolean affectsHealth(String dependencyName) {
|
||||
return correctnessCriticalDependencies.contains(dependencyName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.grpc.admin;
|
||||
|
||||
/**
|
||||
* What a health check reports about one service.
|
||||
*
|
||||
* <p>{@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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<String> registeredServices,
|
||||
Map<String, String> channelProfileHashes,
|
||||
Map<String, String> resolverAndLoadBalancerByChannel,
|
||||
Map<String, String> retryOwnerByChannel,
|
||||
Map<String, GrpcHealthState> 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<String> 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());
|
||||
}
|
||||
}
|
||||
+115
@@ -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.
|
||||
*
|
||||
* <p>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<GrpcPlatformSnapshot> capture(
|
||||
String callerNetwork,
|
||||
Set<String> callerRoles,
|
||||
String snapshotVersion,
|
||||
String schemaVersion,
|
||||
String methodPolicyHash,
|
||||
List<String> registeredServices,
|
||||
Map<String, String> channelProfileHashes,
|
||||
Map<String, String> resolverAndLoadBalancerByChannel,
|
||||
Map<String, String> 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<String> driftAgainstRelease(
|
||||
GrpcPlatformSnapshot running, GrpcPlatformSnapshot released) {
|
||||
if (running == null || released == null) {
|
||||
throw new IllegalArgumentException("a drift comparison needs both snapshots");
|
||||
}
|
||||
List<String> 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);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.grpc.admin;
|
||||
|
||||
/**
|
||||
* Whether one caller may use reflection.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package dev.caskeleton.grpc.admin;
|
||||
|
||||
import dev.caskeleton.grpc.security.GrpcTlsProfile;
|
||||
|
||||
/**
|
||||
* Whether server reflection is exposed, and to whom.
|
||||
*
|
||||
* <p>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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<String> adminNetworks, Set<String> 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<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
+154
@@ -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.
|
||||
*
|
||||
* <p>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<String, GrpcHealthState> states = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, Boolean> 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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<String, GrpcHealthState> snapshot() {
|
||||
return Map.copyOf(states);
|
||||
}
|
||||
|
||||
/** The critical dependencies currently reported unhealthy. */
|
||||
public Set<String> unhealthyCriticalDependencies() {
|
||||
Set<String> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
@@ -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");
|
||||
}
|
||||
}
|
||||
+172
@@ -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<GrpcPlatformSnapshot> capture(
|
||||
String network, Set<String> roles, String schemaVersion, Map<String, String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
+83
@@ -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");
|
||||
}
|
||||
}
|
||||
+91
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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=
|
||||
+32
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<String> credentialFor(Instant at);
|
||||
|
||||
/** A provider that never supplies a credential, for anonymous methods. */
|
||||
static GrpcCallCredentialProvider anonymous() {
|
||||
return at -> Optional.empty();
|
||||
}
|
||||
}
|
||||
+34
@@ -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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
+42
@@ -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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+81
@@ -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.
|
||||
*
|
||||
* <p>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<String> violations(
|
||||
List<GrpcNamedChannelProfile> profiles, Map<String, Integer> resolvedAddressCounts) {
|
||||
if (profiles == null || resolvedAddressCounts == null) {
|
||||
throw new IllegalArgumentException("validation needs the profiles and the resolved counts");
|
||||
}
|
||||
List<String> violations = new ArrayList<>();
|
||||
Map<String, String> 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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+137
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<GrpcChannelProfileName, AtomicReference<GrpcChannelRuntime>> current =
|
||||
new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<GrpcChannelProfileName, List<GrpcChannelRuntime>> 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<GrpcChannelRuntime> 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<GrpcChannelRuntime> 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<GrpcChannelRuntime> find(GrpcChannelProfileName profileName) {
|
||||
AtomicReference<GrpcChannelRuntime> 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<GrpcChannelRuntime> 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<GrpcChannelRuntime> 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<GrpcChannelRuntime> runtimes = draining.get(profileName);
|
||||
if (runtimes == null) {
|
||||
return 0;
|
||||
}
|
||||
List<GrpcChannelRuntime> quiescent =
|
||||
runtimes.stream().filter(GrpcChannelRuntime::quiescent).toList();
|
||||
runtimes.removeAll(quiescent);
|
||||
return quiescent.size();
|
||||
}
|
||||
|
||||
/** The drain policy in force. */
|
||||
public GrpcChannelDrainPolicy drainPolicy() {
|
||||
return drainPolicy;
|
||||
}
|
||||
}
|
||||
+67
@@ -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.
|
||||
*
|
||||
* <p>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<GrpcMetadataKey, String> metadata,
|
||||
Optional<String> 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<GrpcMetadataKey, String> 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));
|
||||
}
|
||||
}
|
||||
+87
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<GrpcMetadataKey> sameTrustDomainKeys,
|
||||
Set<GrpcMetadataKey> 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<GrpcMetadataKey> effectiveAllowlist() {
|
||||
return crossesTrustBoundary ? crossTrustBoundaryKeys : sameTrustDomainKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* The metadata that will actually be sent, with anything outside the allowlist dropped.
|
||||
*
|
||||
* <p>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<GrpcMetadataKey, String> materialize(Map<GrpcMetadataKey, String> proposed) {
|
||||
if (proposed == null) {
|
||||
throw new IllegalArgumentException("proposed metadata must not be null");
|
||||
}
|
||||
Set<GrpcMetadataKey> allowed = effectiveAllowlist();
|
||||
Map<GrpcMetadataKey, String> accepted = new LinkedHashMap<>();
|
||||
proposed.forEach(
|
||||
(key, value) -> {
|
||||
if (allowed.contains(key) && value != null) {
|
||||
accepted.put(key, value);
|
||||
}
|
||||
});
|
||||
budget.check(accepted);
|
||||
return Map.copyOf(accepted);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.grpc.client;
|
||||
|
||||
/**
|
||||
* The load-balancing policies the Stable platform supports.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+100
@@ -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.
|
||||
*
|
||||
* <p>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<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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 <S> the generated stub type
|
||||
*/
|
||||
public record GrpcStubDescriptor<S>(
|
||||
Class<S> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+119
@@ -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.
|
||||
*
|
||||
* <p>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<Class<?>, GrpcStubDescriptor<?>> descriptors = new LinkedHashMap<>();
|
||||
private final Map<Class<?>, Function<GrpcChannelRuntime, ?>> 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> S stubFor(Class<S> 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 <S> GrpcStubDescriptor<?> descriptorFor(Class<S> 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<Class<?>> 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 <S> Builder register(
|
||||
GrpcStubDescriptor<S> descriptor, Function<GrpcChannelRuntime, S> 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();
|
||||
}
|
||||
}
|
||||
+128
@@ -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."));
|
||||
}
|
||||
}
|
||||
+165
@@ -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<GrpcMetadataKey, String> 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<GrpcMetadataKey, String> 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<GrpcMetadataKey, String> 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);
|
||||
}
|
||||
}
|
||||
+159
@@ -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);
|
||||
}
|
||||
}
|
||||
+129
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package dev.caskeleton.grpc.codegen;
|
||||
|
||||
/**
|
||||
* The Buf breaking-change categories, ordered from strictest to loosest.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<String> 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<String> requiredTasks() {
|
||||
return REQUIRED_TASKS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The task names missing from {@code registeredTasks}.
|
||||
*
|
||||
* @return an empty set when the pipeline is complete
|
||||
*/
|
||||
public static Set<String> missingTasks(Set<String> 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);
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package dev.caskeleton.grpc.codegen;
|
||||
|
||||
/**
|
||||
* Who generates Java from the schema, with what versions, into where.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package dev.caskeleton.grpc.codegen;
|
||||
|
||||
/**
|
||||
* Where generated artifacts land.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
+158
@@ -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.
|
||||
*
|
||||
* <p>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<String> requiredServicePaths,
|
||||
Set<String> requiredMethodPaths,
|
||||
Set<String> 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.
|
||||
*
|
||||
* <p>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:
|
||||
*
|
||||
* <ul>
|
||||
* <li>a generated Java package is any package a {@code fixture} class imports from;
|
||||
* <li>a service is a {@code <Name>Grpc} import, mapped back to {@code <package>.<Name>} with
|
||||
* the generated-package suffix removed;
|
||||
* <li>a method is a {@code stub.<name>(} call, mapped to {@code <service>/<UpperCamelName>}.
|
||||
* </ul>
|
||||
*
|
||||
* <p>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<String> javaPackages = new LinkedHashSet<>();
|
||||
java.util.Set<String> 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<String> 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<SourceBreak> breaksAgainst(GrpcDescriptorArtifact candidate) {
|
||||
if (candidate == null) {
|
||||
throw new IllegalArgumentException("a compatibility check needs a candidate artifact");
|
||||
}
|
||||
List<SourceBreak> 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<SourceBreak> breaks, BreakKind kind, Set<String> required, Set<String> available) {
|
||||
Set<String> missing = new LinkedHashSet<>(required);
|
||||
missing.removeAll(available);
|
||||
missing.stream().sorted().forEach(entry -> breaks.add(new SourceBreak(kind, entry)));
|
||||
}
|
||||
}
|
||||
+59
@@ -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.
|
||||
*
|
||||
* <p>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<String> servicePaths,
|
||||
Set<String> methodPaths,
|
||||
Set<String> 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:<digest>'; got '" + digest + "'");
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this artifact is byte-identical to {@code baseline}. */
|
||||
public boolean unchangedFrom(GrpcSchemaBaseline baseline) {
|
||||
return baseline.matches(schemaHash);
|
||||
}
|
||||
}
|
||||
+68
@@ -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.
|
||||
*
|
||||
* <p>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<String> handWrittenPackages, Set<String> 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<String> overlaps() {
|
||||
List<String> 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<String> overlaps = overlaps();
|
||||
if (!overlaps.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"generated and hand-written Java packages must be disjoint: " + overlaps);
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -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.
|
||||
*
|
||||
* <p>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<String, String> 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<String> 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<GrpcConsumerFixture> fixtures) {
|
||||
if (candidate == null || fixtures == null) {
|
||||
throw new IllegalArgumentException("evaluation needs a candidate and a fixture list");
|
||||
}
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.grpc.codegen;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* The released schema a breaking check compares against.
|
||||
*
|
||||
* <p>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:<digest>' 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);
|
||||
}
|
||||
}
|
||||
+88
@@ -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();
|
||||
}
|
||||
}
|
||||
+78
@@ -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);
|
||||
}
|
||||
}
|
||||
+248
@@ -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<String> methods, Set<String> 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<String> METHODS =
|
||||
Set.of(
|
||||
"hyeonworks.document.v1.DocumentService/GetDocument",
|
||||
"hyeonworks.document.v1.DocumentService/CreateDocument");
|
||||
private static final Set<String> 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<GrpcConsumerFixture.SourceBreak> 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<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
+33
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>{@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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package dev.caskeleton.grpc.context;
|
||||
|
||||
/**
|
||||
* Who is calling, as established by authentication — never as claimed by a header.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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");
|
||||
}
|
||||
}
|
||||
+66
@@ -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.
|
||||
*
|
||||
* <p>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<GrpcMetadataKey, String> 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<GrpcMetadataKey, String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package dev.caskeleton.grpc.context;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* A metadata key the platform is willing to carry.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
+87
@@ -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.
|
||||
*
|
||||
* <p>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<GrpcMetadataKey, String> metadata,
|
||||
Optional<String> 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.
|
||||
*
|
||||
* <p>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<GrpcMetadataKey, String> inboundMetadata,
|
||||
Set<GrpcMetadataKey> allowlist,
|
||||
GrpcMetadataBudget budget,
|
||||
String traceId) {
|
||||
if (inboundMetadata == null || allowlist == null || budget == null) {
|
||||
throw new IllegalArgumentException("metadata, allowlist and budget must all be present");
|
||||
}
|
||||
Map<GrpcMetadataKey, String> accepted = new LinkedHashMap<>();
|
||||
for (Map.Entry<GrpcMetadataKey, String> 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<String> 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();
|
||||
}
|
||||
}
|
||||
+24
@@ -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.
|
||||
*
|
||||
* <p>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 + "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package dev.caskeleton.grpc.core;
|
||||
|
||||
/**
|
||||
* Shared validation for the platform's bounded identifiers.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package dev.caskeleton.grpc.core;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* The canonical full method name, {@code package.Service/Method}.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.grpc.core;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* A fully qualified Protobuf service name — {@code package.subpackage.ServiceName}.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
+53
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<String> 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<String> 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<String> modules) {
|
||||
|
||||
private static final Set<String> 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<String> 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<String> 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}.
|
||||
*
|
||||
* <p>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<String> advancedLeaks(Set<String> candidateDependencies) {
|
||||
Set<String> leaks = new LinkedHashSet<>(candidateDependencies);
|
||||
leaks.retainAll(ADVANCED);
|
||||
return Set.copyOf(leaks);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package dev.caskeleton.grpc.core;
|
||||
|
||||
/**
|
||||
* The four gRPC method shapes.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+68
@@ -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.
|
||||
*
|
||||
* <p>"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> 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> cancellation() {
|
||||
return Optional.ofNullable(cancellation.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails when the token has already fired.
|
||||
*
|
||||
* <p>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()
|
||||
+ ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -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.
|
||||
*
|
||||
* <p>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}.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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();
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package dev.caskeleton.grpc.deadline;
|
||||
|
||||
import dev.caskeleton.grpc.error.GrpcFailureContext;
|
||||
import dev.caskeleton.grpc.error.GrpcPlatformException;
|
||||
|
||||
/**
|
||||
* A deadline elapsed.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
+59
@@ -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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
+69
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>{@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;
|
||||
}
|
||||
}
|
||||
+64
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+99
@@ -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.
|
||||
*
|
||||
* <p>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<String> 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.
|
||||
*
|
||||
* <p>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("");
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.caskeleton.grpc.error;
|
||||
|
||||
/**
|
||||
* The platform's failure carrier.
|
||||
*
|
||||
* <p>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();
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.grpc.evidence;
|
||||
|
||||
/**
|
||||
* What is known about the business effect — the second evidence axis.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>{@link #COMMIT_UNKNOWN} answers false — which is the whole reason the value exists.
|
||||
*/
|
||||
public boolean safeToRepeatWithoutGuard() {
|
||||
return this == NONE || this == REJECTED;
|
||||
}
|
||||
}
|
||||
+87
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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();
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package dev.caskeleton.grpc.evidence;
|
||||
|
||||
/**
|
||||
* What a stream delivered — the third evidence axis.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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}.
|
||||
*
|
||||
* <p>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}.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.grpc.evidence;
|
||||
|
||||
/**
|
||||
* What the transport was observed to do — the first of the three independent evidence axes.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package dev.caskeleton.grpc.ledger;
|
||||
|
||||
import dev.caskeleton.grpc.core.GrpcMethodName;
|
||||
|
||||
/**
|
||||
* What makes two requests the same operation.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.grpc.ledger;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The durable claim store behind idempotent mutations.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<GrpcOperationLedgerRecord> claim(
|
||||
GrpcOperationIdentity identity, String requestFingerprint, Instant now);
|
||||
|
||||
/** The record for {@code identity}, if there is one. */
|
||||
Optional<GrpcOperationLedgerRecord> find(GrpcOperationIdentity identity);
|
||||
|
||||
/**
|
||||
* Marks a claim committed with a replayable outcome.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
+59
@@ -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.
|
||||
*
|
||||
* <p>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<String> outcomeReference,
|
||||
Instant claimedAt,
|
||||
Optional<Instant> 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);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.grpc.ledger;
|
||||
|
||||
/**
|
||||
* Where a durable operation claim is in its life.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+100
@@ -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.
|
||||
*
|
||||
* <p>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();
|
||||
}
|
||||
}
|
||||
+124
@@ -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.
|
||||
*
|
||||
* <p>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<GrpcMethodName, GrpcMethodPolicy> policies;
|
||||
|
||||
private GrpcMethodPolicyCatalog(Map<GrpcMethodName, GrpcMethodPolicy> 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<GrpcMethodPolicy> 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<GrpcMethodName> 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<GrpcMethodName, GrpcMethodPolicy> policies = new LinkedHashMap<>();
|
||||
private final Set<GrpcMethodName> descriptorMethods = new LinkedHashSet<>();
|
||||
private boolean descriptorDeclared;
|
||||
|
||||
private Builder() {}
|
||||
|
||||
/**
|
||||
* Declares the methods the compiled schema actually contains.
|
||||
*
|
||||
* <p>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<GrpcMethodName> 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<GrpcMethodName> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.grpc.policy;
|
||||
|
||||
/**
|
||||
* Whether a call queues while the channel is disconnected, instead of failing fast.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+174
@@ -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<GrpcMetadataKey, String> oversized = new LinkedHashMap<>();
|
||||
oversized.put(CORRELATION, "x".repeat(64));
|
||||
|
||||
assertThatThrownBy(() -> tight.check(oversized))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("user-defined metadata");
|
||||
|
||||
Map<GrpcMetadataKey, String> 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<GrpcMetadataKey, String> 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<GrpcMetadataKey, String> 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();
|
||||
}
|
||||
}
|
||||
+83
@@ -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);
|
||||
}
|
||||
}
|
||||
+60
@@ -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<String> 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"));
|
||||
}
|
||||
}
|
||||
+124
@@ -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");
|
||||
}
|
||||
}
|
||||
+182
@@ -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);
|
||||
}
|
||||
}
|
||||
+120
@@ -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);
|
||||
}
|
||||
}
|
||||
+183
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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=
|
||||
+67
@@ -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.
|
||||
*
|
||||
* <p>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<String> 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<String> violations(GrpcResolverProfile profile) {
|
||||
if (profile == null) {
|
||||
throw new IllegalArgumentException("a resolver profile is required");
|
||||
}
|
||||
List<String> 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"));
|
||||
}
|
||||
}
|
||||
+90
@@ -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.
|
||||
*
|
||||
* <p>{@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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user