feat: grpc 기능 deep 구현
This commit is contained in:
+7
-4
@@ -477,12 +477,15 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The messaging platform leaves own a broker-neutral public contract. Keeping their test
|
||||
// classpath on plain JUnit + AssertJ is what makes "messaging-core-api has no Spring
|
||||
// dependency" verifiable rather than aspirational; leaves that genuinely need a Spring
|
||||
// The messaging and gRPC platform leaves own a transport-neutral public contract. Keeping
|
||||
// their test classpath on plain JUnit + AssertJ is what makes "messaging-core-api has no
|
||||
// Spring dependency" — and the same claim for grpc-core-api, which additionally may not
|
||||
// name io.grpc — verifiable rather than aspirational; leaves that genuinely need a Spring
|
||||
// test context add it in their own build file.
|
||||
if (project.path in [':domain-core', ':application-core', ':shared-contract'] ||
|
||||
project.path.startsWith(':messaging:')) {
|
||||
project.path.startsWith(':messaging:') ||
|
||||
project.path.startsWith(':grpc:') ||
|
||||
project.path.startsWith(':grpc-advanced:')) {
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
testImplementation 'org.assertj:assertj-core'
|
||||
} else {
|
||||
|
||||
@@ -587,6 +587,203 @@
|
||||
"messaging-transport-spi"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-core-api",
|
||||
"gradle_path": ":grpc:grpc-core-api",
|
||||
"source_path": "src/grpc/grpc-core-api",
|
||||
"allowed_dependencies": [],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-proto-contract",
|
||||
"gradle_path": ":grpc:grpc-proto-contract",
|
||||
"source_path": "src/grpc/grpc-proto-contract",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-codegen",
|
||||
"gradle_path": ":grpc:grpc-codegen",
|
||||
"source_path": "src/grpc/grpc-codegen",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-proto-contract"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-policy",
|
||||
"gradle_path": ":grpc:grpc-policy",
|
||||
"source_path": "src/grpc/grpc-policy",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-server",
|
||||
"gradle_path": ":grpc:grpc-server",
|
||||
"source_path": "src/grpc/grpc-server",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-policy"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-client",
|
||||
"gradle_path": ":grpc:grpc-client",
|
||||
"source_path": "src/grpc/grpc-client",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-policy"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-discovery",
|
||||
"gradle_path": ":grpc:grpc-discovery",
|
||||
"source_path": "src/grpc/grpc-discovery",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-client"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-admin",
|
||||
"gradle_path": ":grpc:grpc-admin",
|
||||
"source_path": "src/grpc/grpc-admin",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-server"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-observability",
|
||||
"gradle_path": ":grpc:grpc-observability",
|
||||
"source_path": "src/grpc/grpc-observability",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-operation-ledger-jpa",
|
||||
"gradle_path": ":grpc:grpc-operation-ledger-jpa",
|
||||
"source_path": "src/grpc/grpc-operation-ledger-jpa",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-spring-boot-starter",
|
||||
"gradle_path": ":grpc:grpc-spring-boot-starter",
|
||||
"source_path": "src/grpc/grpc-spring-boot-starter",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-proto-contract",
|
||||
"grpc-codegen",
|
||||
"grpc-policy",
|
||||
"grpc-server",
|
||||
"grpc-client",
|
||||
"grpc-discovery",
|
||||
"grpc-admin",
|
||||
"grpc-observability",
|
||||
"grpc-operation-ledger-jpa"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-testkit",
|
||||
"gradle_path": ":grpc:grpc-testkit",
|
||||
"source_path": "src/grpc/grpc-testkit",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-proto-contract",
|
||||
"grpc-codegen",
|
||||
"grpc-policy",
|
||||
"grpc-server",
|
||||
"grpc-client",
|
||||
"grpc-discovery",
|
||||
"grpc-admin",
|
||||
"grpc-observability",
|
||||
"grpc-operation-ledger-jpa"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-advanced-bootstrap",
|
||||
"gradle_path": ":grpc-advanced:grpc-advanced-bootstrap",
|
||||
"source_path": "src/grpc-advanced/grpc-advanced-bootstrap",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-advanced-edition",
|
||||
"gradle_path": ":grpc-advanced:grpc-advanced-edition",
|
||||
"source_path": "src/grpc-advanced/grpc-advanced-edition",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-proto-contract",
|
||||
"grpc-advanced-bootstrap"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-advanced-streaming",
|
||||
"gradle_path": ":grpc-advanced:grpc-advanced-streaming",
|
||||
"source_path": "src/grpc-advanced/grpc-advanced-streaming",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-policy",
|
||||
"grpc-advanced-bootstrap"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-advanced-resilience",
|
||||
"gradle_path": ":grpc-advanced:grpc-advanced-resilience",
|
||||
"source_path": "src/grpc-advanced/grpc-advanced-resilience",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-policy",
|
||||
"grpc-client",
|
||||
"grpc-discovery",
|
||||
"grpc-advanced-bootstrap"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-advanced-compat",
|
||||
"gradle_path": ":grpc-advanced:grpc-advanced-compat",
|
||||
"source_path": "src/grpc-advanced/grpc-advanced-compat",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-policy",
|
||||
"grpc-server",
|
||||
"grpc-client",
|
||||
"grpc-advanced-bootstrap"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-advanced-diagnostics",
|
||||
"gradle_path": ":grpc-advanced:grpc-advanced-diagnostics",
|
||||
"source_path": "src/grpc-advanced/grpc-advanced-diagnostics",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-client",
|
||||
"grpc-advanced-bootstrap"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# grpc-advanced — local authority for the advanced gRPC capabilities
|
||||
|
||||
이 문서는 `grpc-advanced:*` family의 **local authority**다. leaf 목록·gradle path·허용 의존성은
|
||||
`src/config/architecture/modules.json`이 SSOT다. Root 정책(`CLAUDE.md` / `AGENTS.md`)과 충돌하면
|
||||
root가 이긴다.
|
||||
|
||||
이 family는 Stable gRPC 플랫폼(`grpc:*`)이 **의도적으로 제외한** 능력들을 담는다. 별도 디렉터리와
|
||||
별도 Gradle prefix인 이유는 하나다: "Stable starter가 advanced module을 참조하면 build가 실패한다"는
|
||||
불변 조건을 registry의 `allowed_dependencies`만으로 기계 검증할 수 있게 하기 위해서다.
|
||||
|
||||
## 의존 방향
|
||||
|
||||
```text
|
||||
grpc-advanced:* → grpc:* (허용)
|
||||
grpc:* → grpc-advanced:* (금지 — registry가 거부한다)
|
||||
```
|
||||
|
||||
`grpc-spring-boot-starter`의 registry 엔트리에는 어떤 advanced id도 없다.
|
||||
`verifyCleanArchitectureDependencies`가 build time에, `GrpcStableBuildInvariant`와
|
||||
`GrpcAdvancedModuleGuard.requireStableStarterIsClean`이 runtime에 같은 규칙을 강제한다.
|
||||
|
||||
## Capability grade와 feature flag
|
||||
|
||||
capability마다 별도 flag를 갖는다. 하나의 "advanced" 스위치로 묶지 않는 이유는, gRPC-Web을 켜는
|
||||
결정(프록시 하나)과 xDS를 켜는 결정(control plane과 그 장애 모드 전체)이 같은 결정이 아니기
|
||||
때문이다. 하나의 스위치는 두 번째 결정을 실수로 내리게 만든다.
|
||||
|
||||
| grade | 시작 가능 | production 추가 승인 |
|
||||
| --- | --- | --- |
|
||||
| `ADVANCED_STABLE` | O | 불필요 |
|
||||
| `EXPERIMENTAL` | O | **필요** |
|
||||
| `WATCH` | X (추적만) | — |
|
||||
| `DISABLED` | X | — |
|
||||
|
||||
property key는 `ca-skeleton.grpc.advanced.<capability>.enabled`이고 전부 기본 off다.
|
||||
`GrpcAdvancedModuleGuard`가 세 조건(flag 미설정 / grade가 시작 불가 / production 승인 없음)을
|
||||
구분해서 거부하며, 세 경우의 조치가 다르므로 메시지도 다르다.
|
||||
|
||||
## Leaf별 담당 capability
|
||||
|
||||
| leaf | capability |
|
||||
| --- | --- |
|
||||
| `grpc-advanced-bootstrap` | capability grade, feature flag, module guard, capability별 promotion gate |
|
||||
| `grpc-advanced-edition` | Protobuf Edition 2024 opt-in lane, Edition 2026 watch lane |
|
||||
| `grpc-advanced-streaming` | client streaming(session/dedup/checkpoint), bidi(dual sequence), manual flow control |
|
||||
| `grpc-advanced-resilience` | read-only hedging, custom name resolver SPI, custom load balancer SPI, proxyless xDS |
|
||||
| `grpc-advanced-compat` | gRPC-Web, Servlet HTTP/2, Spring Integration bridge, Reactor adapter, Kotlin 경계 |
|
||||
| `grpc-advanced-diagnostics` | Channelz/CSDS 진단, advanced infrastructure testkit 요구사항 |
|
||||
|
||||
## Promotion은 capability별로 독립이다
|
||||
|
||||
`GrpcAdvancedPromotionEvidence`는 capability마다 별도 레코드다. 공유 레코드였다면 하나를 승격할 때
|
||||
같은 시점에 측정된 다른 것들이 함께 승격된다. `GrpcAdvancedPromotionGate.capabilitiesDraggedAlong`이
|
||||
항상 빈 리스트인 것은 주석이 아니라 테스트되는 속성이다.
|
||||
|
||||
승격 문턱은 두 개다: `ADVANCED_STABLE`은 7일 soak + 전체 증거, Stable default는 30일 soak. 두 번째가
|
||||
더 높은 이유는 모든 배포가 그 의존성과 장애 모드를 갖게 되기 때문이다.
|
||||
|
||||
## 이 저장소에서 검증할 수 없는 것
|
||||
|
||||
`GrpcAdvancedInfrastructureTestkit`이 capability별로 필요한 실제 인프라를 명시한다.
|
||||
|
||||
- `grpc-web` → gRPC-Web 프록시
|
||||
- `servlet-compat` → Servlet 컨테이너
|
||||
- `xds` → 중지 가능한 xDS control plane
|
||||
- `kotlin` → Kotlin 툴체인 (**이 저장소에 없다**)
|
||||
|
||||
인프라 없이 도는 suite는 통과하면서 아무것도 증명하지 않으므로, suite가 없는 것보다 나쁘다.
|
||||
`GrpcKotlinCompatibilityGate.supportableHere()`가 `false`를 반환하는 것은 그 사실의 코드 표현이다 —
|
||||
Kotlin 계약 요구사항 4개는 검증되지만 compile lane은 존재하지 않는다.
|
||||
|
||||
## 금지
|
||||
|
||||
- Stable leaf(`grpc:*`)가 이 family를 참조하는 것.
|
||||
- capability grade 없이, 또는 flag 없이 advanced 코드 경로를 실행하는 것.
|
||||
- `WATCH` capability를 스키마 소스로 사용하는 것 (`GrpcEdition2026Guard`가 무조건 거부한다).
|
||||
- 실제 인프라 없이 실행한 suite를 promotion evidence로 인용하는 것.
|
||||
@@ -0,0 +1,12 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
// The Advanced boundary itself: capability grades, the `ca-skeleton.grpc.advanced.*` feature-flag
|
||||
// contract, the module guard that refuses an unflagged capability, and the per-capability
|
||||
// promotion gate.
|
||||
//
|
||||
// This leaf depends on Stable public types and never the other way round. The Stable starter's
|
||||
// registry entry names no advanced id, so `verifyCleanArchitectureDependencies` is what makes
|
||||
// "Advanced never leaks into Stable" a build failure rather than a review note.
|
||||
dependencies {
|
||||
api project(':grpc:grpc-core-api')
|
||||
}
|
||||
@@ -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
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package dev.caskeleton.grpc.advanced.bootstrap;
|
||||
|
||||
/**
|
||||
* Every capability the Stable platform deliberately excludes, and how ready each one is.
|
||||
*
|
||||
* <p>Grading them individually is the design. Bundling them under one "advanced" flag makes
|
||||
* enabling gRPC-Web — a compatibility bridge with a proxy in front of it — the same decision as
|
||||
* enabling xDS, which brings a control plane and its outage modes. They are not the same decision,
|
||||
* and a single switch is how the second one gets made by accident.
|
||||
*/
|
||||
public enum GrpcAdvancedCapability {
|
||||
/** Protobuf Edition 2024 as an opt-in schema lane. */
|
||||
EDITION_2024("edition-2024", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** Protobuf Edition 2026. Recorded, not usable. */
|
||||
EDITION_2026("edition-2026", GrpcCapabilityGrade.WATCH),
|
||||
/** Client streaming with session, dedup and checkpoint. */
|
||||
CLIENT_STREAMING("client-streaming", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** Bidirectional streaming with independent per-direction sequences. */
|
||||
BIDI_STREAMING("bidi-streaming", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** Manual flow control, for approved streaming methods. */
|
||||
MANUAL_FLOW_CONTROL("manual-flow-control", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** Read-only unary hedging. */
|
||||
HEDGING("hedging", GrpcCapabilityGrade.EXPERIMENTAL),
|
||||
/** A custom name resolver. */
|
||||
CUSTOM_RESOLVER("custom-resolver", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** A custom load balancer. */
|
||||
CUSTOM_LOAD_BALANCER("custom-load-balancer", GrpcCapabilityGrade.EXPERIMENTAL),
|
||||
/** Proxyless xDS. */
|
||||
XDS("xds", GrpcCapabilityGrade.EXPERIMENTAL),
|
||||
/** The gRPC-Web bridge. */
|
||||
GRPC_WEB("grpc-web", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** A Servlet container owning the HTTP/2 socket. */
|
||||
SERVLET_COMPAT("servlet-compat", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** The Spring Integration bridge. */
|
||||
INTEGRATION_BRIDGE("integration-bridge", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** The Reactor adapter. */
|
||||
REACTOR("reactor", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** The Kotlin coroutine and Flow adapter. */
|
||||
KOTLIN("kotlin", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** Channelz and CSDS diagnostics. */
|
||||
CHANNEL_DIAGNOSTICS("channel-diagnostics", GrpcCapabilityGrade.ADVANCED_STABLE);
|
||||
|
||||
private final String flagName;
|
||||
private final GrpcCapabilityGrade defaultGrade;
|
||||
|
||||
GrpcAdvancedCapability(String flagName, GrpcCapabilityGrade defaultGrade) {
|
||||
this.flagName = flagName;
|
||||
this.defaultGrade = defaultGrade;
|
||||
}
|
||||
|
||||
/** The property suffix under {@code ca-skeleton.grpc.advanced}. */
|
||||
public String flagName() {
|
||||
return flagName;
|
||||
}
|
||||
|
||||
/** The full property key that enables this capability. */
|
||||
public String propertyKey() {
|
||||
return "ca-skeleton.grpc.advanced." + flagName + ".enabled";
|
||||
}
|
||||
|
||||
/** How ready this capability is today. */
|
||||
public GrpcCapabilityGrade defaultGrade() {
|
||||
return defaultGrade;
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package dev.caskeleton.grpc.advanced.bootstrap;
|
||||
|
||||
/**
|
||||
* A capability was used without being enabled.
|
||||
*
|
||||
* <p>The message carries the property key. An advanced capability is off by default and the refusal
|
||||
* is the first thing a developer meets when trying it; telling them which key to set turns a
|
||||
* support question into a configuration line.
|
||||
*/
|
||||
public class GrpcAdvancedCapabilityDisabledException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient GrpcAdvancedCapability capability;
|
||||
|
||||
/** Refuses use of a disabled capability. */
|
||||
public GrpcAdvancedCapabilityDisabledException(GrpcAdvancedCapability capability, String reason) {
|
||||
super(render(capability, reason));
|
||||
this.capability = capability;
|
||||
}
|
||||
|
||||
private static String render(GrpcAdvancedCapability capability, String reason) {
|
||||
if (capability == null) {
|
||||
throw new IllegalArgumentException("a capability is required");
|
||||
}
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new IllegalArgumentException("a refusal explains itself");
|
||||
}
|
||||
return "advanced capability '"
|
||||
+ capability.flagName()
|
||||
+ "' is not available: "
|
||||
+ reason
|
||||
+ " (set "
|
||||
+ capability.propertyKey()
|
||||
+ "=true to enable it)";
|
||||
}
|
||||
|
||||
/** The capability that was refused. */
|
||||
public GrpcAdvancedCapability capability() {
|
||||
return capability;
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package dev.caskeleton.grpc.advanced.bootstrap;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Which advanced capabilities a deployment has turned on.
|
||||
*
|
||||
* <p>Everything is off unless named. A capability that switches itself on because its jar is
|
||||
* present is a capability nobody decided to run, and the ones here bring proxies, control planes
|
||||
* and duplicate request load with them.
|
||||
*/
|
||||
public final class GrpcAdvancedFeatureFlags {
|
||||
|
||||
private final Map<GrpcAdvancedCapability, Boolean> enabled =
|
||||
new EnumMap<>(GrpcAdvancedCapability.class);
|
||||
private final Map<GrpcAdvancedCapability, GrpcCapabilityGrade> grades =
|
||||
new EnumMap<>(GrpcAdvancedCapability.class);
|
||||
private final boolean production;
|
||||
private final Set<GrpcAdvancedCapability> productionApprovals;
|
||||
|
||||
private GrpcAdvancedFeatureFlags(
|
||||
boolean production, Set<GrpcAdvancedCapability> productionApprovals) {
|
||||
this.production = production;
|
||||
this.productionApprovals =
|
||||
EnumSet.copyOf(
|
||||
productionApprovals.isEmpty()
|
||||
? EnumSet.noneOf(GrpcAdvancedCapability.class)
|
||||
: EnumSet.copyOf(productionApprovals));
|
||||
for (GrpcAdvancedCapability capability : GrpcAdvancedCapability.values()) {
|
||||
enabled.put(capability, false);
|
||||
grades.put(capability, capability.defaultGrade());
|
||||
}
|
||||
}
|
||||
|
||||
/** Flags for a non-production environment. */
|
||||
public static GrpcAdvancedFeatureFlags forDevelopment() {
|
||||
return new GrpcAdvancedFeatureFlags(false, Set.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags for production.
|
||||
*
|
||||
* @param productionApprovals the experimental capabilities somebody has accepted the risk of
|
||||
*/
|
||||
public static GrpcAdvancedFeatureFlags forProduction(
|
||||
Set<GrpcAdvancedCapability> productionApprovals) {
|
||||
if (productionApprovals == null) {
|
||||
throw new IllegalArgumentException("an approval set is required, even if empty");
|
||||
}
|
||||
return new GrpcAdvancedFeatureFlags(true, productionApprovals);
|
||||
}
|
||||
|
||||
/** Turns a capability on. */
|
||||
public GrpcAdvancedFeatureFlags enable(GrpcAdvancedCapability capability) {
|
||||
if (capability == null) {
|
||||
throw new IllegalArgumentException("a capability is required");
|
||||
}
|
||||
enabled.put(capability, true);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Overrides a capability's grade, for a deployment that has its own evidence. */
|
||||
public GrpcAdvancedFeatureFlags withGrade(
|
||||
GrpcAdvancedCapability capability, GrpcCapabilityGrade grade) {
|
||||
if (capability == null || grade == null) {
|
||||
throw new IllegalArgumentException("a grade override needs both parts");
|
||||
}
|
||||
grades.put(capability, grade);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Whether the flag is set, regardless of whether the capability may actually start. */
|
||||
public boolean flagSet(GrpcAdvancedCapability capability) {
|
||||
return Boolean.TRUE.equals(enabled.get(capability));
|
||||
}
|
||||
|
||||
/** The grade in force for a capability. */
|
||||
public GrpcCapabilityGrade gradeOf(GrpcAdvancedCapability capability) {
|
||||
return grades.get(capability);
|
||||
}
|
||||
|
||||
/** Whether this deployment is production. */
|
||||
public boolean production() {
|
||||
return production;
|
||||
}
|
||||
|
||||
/** Whether an experimental capability has been separately approved for production. */
|
||||
public boolean productionApproved(GrpcAdvancedCapability capability) {
|
||||
return productionApprovals.contains(capability);
|
||||
}
|
||||
|
||||
/** The capabilities that are both flagged and permitted to start. */
|
||||
public Set<GrpcAdvancedCapability> active() {
|
||||
Set<GrpcAdvancedCapability> running = EnumSet.noneOf(GrpcAdvancedCapability.class);
|
||||
for (GrpcAdvancedCapability capability : GrpcAdvancedCapability.values()) {
|
||||
if (GrpcAdvancedModuleGuard.available(this, capability)) {
|
||||
running.add(capability);
|
||||
}
|
||||
}
|
||||
return Set.copyOf(running);
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package dev.caskeleton.grpc.advanced.bootstrap;
|
||||
|
||||
import dev.caskeleton.grpc.core.GrpcStableBuildInvariant;
|
||||
import dev.caskeleton.grpc.core.GrpcStableModuleCatalog;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The single gate every advanced capability passes through.
|
||||
*
|
||||
* <p>Three conditions, checked in this order because each explains a different refusal: the flag is
|
||||
* not set, the grade cannot start at all, or production has not separately approved an experimental
|
||||
* capability. Collapsing them into one boolean produces a "not enabled" message for three
|
||||
* situations with three different remedies.
|
||||
*/
|
||||
public final class GrpcAdvancedModuleGuard {
|
||||
|
||||
private GrpcAdvancedModuleGuard() {}
|
||||
|
||||
/** Whether {@code capability} may run under {@code flags}. */
|
||||
public static boolean available(
|
||||
GrpcAdvancedFeatureFlags flags, GrpcAdvancedCapability capability) {
|
||||
if (flags == null || capability == null) {
|
||||
throw new IllegalArgumentException("availability needs flags and a capability");
|
||||
}
|
||||
if (!flags.flagSet(capability)) {
|
||||
return false;
|
||||
}
|
||||
GrpcCapabilityGrade grade = flags.gradeOf(capability);
|
||||
if (!grade.startable()) {
|
||||
return false;
|
||||
}
|
||||
return !(flags.production()
|
||||
&& grade.requiresProductionApproval()
|
||||
&& !flags.productionApproved(capability));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails when {@code capability} may not run.
|
||||
*
|
||||
* @throws GrpcAdvancedCapabilityDisabledException naming which of the three conditions failed
|
||||
*/
|
||||
public static void require(GrpcAdvancedFeatureFlags flags, GrpcAdvancedCapability capability) {
|
||||
if (flags == null || capability == null) {
|
||||
throw new IllegalArgumentException("a guard needs flags and a capability");
|
||||
}
|
||||
if (!flags.flagSet(capability)) {
|
||||
throw new GrpcAdvancedCapabilityDisabledException(capability, "its feature flag is not set");
|
||||
}
|
||||
GrpcCapabilityGrade grade = flags.gradeOf(capability);
|
||||
if (!grade.startable()) {
|
||||
throw new GrpcAdvancedCapabilityDisabledException(
|
||||
capability,
|
||||
"it is graded "
|
||||
+ grade
|
||||
+ ", which cannot start; a WATCH capability is tracked rather than implemented");
|
||||
}
|
||||
if (flags.production()
|
||||
&& grade.requiresProductionApproval()
|
||||
&& !flags.productionApproved(capability)) {
|
||||
throw new GrpcAdvancedCapabilityDisabledException(
|
||||
capability,
|
||||
"it is "
|
||||
+ grade
|
||||
+ " and production needs a separate approval; the flag says somebody wanted it, not "
|
||||
+ "that somebody accepted its uncharacterised failure modes");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails when the Stable starter's dependency set reaches an advanced module.
|
||||
*
|
||||
* <p>The same invariant the registry enforces at build time, asserted here so a runtime that was
|
||||
* assembled some other way — a fat jar, a shaded artifact, a test harness — is checked too.
|
||||
*/
|
||||
public static void requireStableStarterIsClean(Set<String> starterDependencies) {
|
||||
GrpcStableBuildInvariant.requireNoAdvancedDependency(
|
||||
"grpc-spring-boot-starter", starterDependencies);
|
||||
}
|
||||
|
||||
/** The advanced module ids, for a runtime classpath check. */
|
||||
public static Set<String> advancedModules() {
|
||||
return GrpcStableModuleCatalog.advancedModules();
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.grpc.advanced.bootstrap;
|
||||
|
||||
/**
|
||||
* How much a capability has been established, and what that permits.
|
||||
*
|
||||
* <p>{@link #EXPERIMENTAL} in production needs a second, separate approval rather than the
|
||||
* capability flag alone. The flag says somebody wanted the feature; the approval says somebody
|
||||
* accepted that its failure modes are not fully characterised, which is a different person's
|
||||
* decision on most teams.
|
||||
*/
|
||||
public enum GrpcCapabilityGrade {
|
||||
/** Contract, fault and operational evidence exist. Enable with the capability flag. */
|
||||
ADVANCED_STABLE(true, false),
|
||||
/** Works, but its failure modes are not fully characterised. Needs a production approval too. */
|
||||
EXPERIMENTAL(true, true),
|
||||
/** Tracked, not implemented. Cannot be enabled. */
|
||||
WATCH(false, false),
|
||||
/** Withdrawn or refused. Cannot be enabled. */
|
||||
DISABLED(false, false);
|
||||
|
||||
private final boolean startable;
|
||||
private final boolean requiresProductionApproval;
|
||||
|
||||
GrpcCapabilityGrade(boolean startable, boolean requiresProductionApproval) {
|
||||
this.startable = startable;
|
||||
this.requiresProductionApproval = requiresProductionApproval;
|
||||
}
|
||||
|
||||
/** Whether a deployment may run this capability at all. */
|
||||
public boolean startable() {
|
||||
return startable;
|
||||
}
|
||||
|
||||
/** Whether production additionally requires an explicit approval. */
|
||||
public boolean requiresProductionApproval() {
|
||||
return requiresProductionApproval;
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.grpc.advanced.release;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcCapabilityGrade;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Whether one capability moves to a new grade.
|
||||
*
|
||||
* <p>Carries the grade it would move to as well as the blockers, so a refusal says what was being
|
||||
* asked for. "Not promoted" is ambiguous between a failed promotion to Advanced Stable and a failed
|
||||
* promotion to a Stable default, and the second is a much larger decision.
|
||||
*/
|
||||
public record GrpcAdvancedPromotionDecision(
|
||||
GrpcAdvancedCapability capability,
|
||||
GrpcCapabilityGrade from,
|
||||
GrpcCapabilityGrade to,
|
||||
boolean promoted,
|
||||
List<String> blockers) {
|
||||
|
||||
/** Requires blockers exactly when refused. */
|
||||
public GrpcAdvancedPromotionDecision {
|
||||
if (capability == null || from == null || to == null || blockers == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"a promotion decision names its capability and both grades");
|
||||
}
|
||||
blockers = List.copyOf(blockers);
|
||||
if (promoted && !blockers.isEmpty()) {
|
||||
throw new IllegalArgumentException("a granted promotion has no blockers");
|
||||
}
|
||||
if (!promoted && blockers.isEmpty()) {
|
||||
throw new IllegalArgumentException("a refused promotion says why");
|
||||
}
|
||||
}
|
||||
|
||||
/** A granted promotion. */
|
||||
public static GrpcAdvancedPromotionDecision grant(
|
||||
GrpcAdvancedCapability capability, GrpcCapabilityGrade from, GrpcCapabilityGrade to) {
|
||||
return new GrpcAdvancedPromotionDecision(capability, from, to, true, List.of());
|
||||
}
|
||||
|
||||
/** A refused promotion. */
|
||||
public static GrpcAdvancedPromotionDecision refuse(
|
||||
GrpcAdvancedCapability capability,
|
||||
GrpcCapabilityGrade from,
|
||||
GrpcCapabilityGrade to,
|
||||
List<String> blockers) {
|
||||
return new GrpcAdvancedPromotionDecision(capability, from, to, false, blockers);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package dev.caskeleton.grpc.advanced.release;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What one capability has behind it.
|
||||
*
|
||||
* <p>Per capability, never shared. The Stable plan's requirement that Edition, streaming, xDS,
|
||||
* gRPC-Web, Servlet and the language adapters do not gate each other only means something if their
|
||||
* evidence is separate: a shared record makes promoting one of them promote whichever others
|
||||
* happened to be measured at the same time.
|
||||
*/
|
||||
public record GrpcAdvancedPromotionEvidence(
|
||||
GrpcAdvancedCapability capability,
|
||||
boolean compatibilityEvidence,
|
||||
boolean securityReview,
|
||||
boolean faultEvidence,
|
||||
boolean performanceEvidence,
|
||||
Duration soakDuration,
|
||||
boolean architectureDecisionRecord,
|
||||
boolean runbook,
|
||||
boolean realEnvironmentTest) {
|
||||
|
||||
/** Requires a capability and a non-negative soak. */
|
||||
public GrpcAdvancedPromotionEvidence {
|
||||
if (capability == null) {
|
||||
throw new IllegalArgumentException("promotion evidence names its capability");
|
||||
}
|
||||
if (soakDuration == null || soakDuration.isNegative()) {
|
||||
throw new IllegalArgumentException("a soak duration must be present and non-negative");
|
||||
}
|
||||
}
|
||||
|
||||
/** No evidence at all, which is where a capability starts. */
|
||||
public static GrpcAdvancedPromotionEvidence none(GrpcAdvancedCapability capability) {
|
||||
return new GrpcAdvancedPromotionEvidence(
|
||||
capability, false, false, false, false, Duration.ZERO, false, false, false);
|
||||
}
|
||||
|
||||
/** Everything a promotion to Advanced Stable needs. */
|
||||
public static GrpcAdvancedPromotionEvidence complete(
|
||||
GrpcAdvancedCapability capability, Duration soakDuration) {
|
||||
return new GrpcAdvancedPromotionEvidence(
|
||||
capability, true, true, true, true, soakDuration, true, true, true);
|
||||
}
|
||||
|
||||
/** Which required items are absent, as a set a report can print. */
|
||||
public Set<String> missing() {
|
||||
Set<String> missing = new java.util.LinkedHashSet<>();
|
||||
if (!compatibilityEvidence) {
|
||||
missing.add("compatibility evidence");
|
||||
}
|
||||
if (!securityReview) {
|
||||
missing.add("security review");
|
||||
}
|
||||
if (!faultEvidence) {
|
||||
missing.add("fault evidence");
|
||||
}
|
||||
if (!performanceEvidence) {
|
||||
missing.add("performance evidence");
|
||||
}
|
||||
if (!architectureDecisionRecord) {
|
||||
missing.add("architecture decision record");
|
||||
}
|
||||
if (!runbook) {
|
||||
missing.add("runbook");
|
||||
}
|
||||
if (!realEnvironmentTest) {
|
||||
missing.add("real environment test");
|
||||
}
|
||||
return Set.copyOf(missing);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package dev.caskeleton.grpc.advanced.release;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcCapabilityGrade;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Decides one capability's promotion, on its own evidence.
|
||||
*
|
||||
* <p>Two thresholds rather than one. Reaching Advanced Stable means the capability works and is
|
||||
* documented; becoming a Stable default means every deployment gets it, which additionally puts its
|
||||
* dependencies on every classpath and its failure modes in every on-call rotation. The second needs
|
||||
* the first plus a longer soak, because a capability that has run in one deployment for a week is
|
||||
* not the same claim as one that ships to all of them.
|
||||
*/
|
||||
public final class GrpcAdvancedPromotionGate {
|
||||
|
||||
/** The soak a promotion to Advanced Stable requires. */
|
||||
public static final Duration ADVANCED_STABLE_SOAK = Duration.ofDays(7);
|
||||
|
||||
/** The soak a promotion to a Stable default requires. */
|
||||
public static final Duration STABLE_DEFAULT_SOAK = Duration.ofDays(30);
|
||||
|
||||
private GrpcAdvancedPromotionGate() {}
|
||||
|
||||
/**
|
||||
* Whether {@code capability} may move from {@code from} to {@code to}.
|
||||
*
|
||||
* @throws IllegalArgumentException when the transition is not one this gate governs
|
||||
*/
|
||||
public static GrpcAdvancedPromotionDecision evaluate(
|
||||
GrpcAdvancedPromotionEvidence evidence, GrpcCapabilityGrade from, GrpcCapabilityGrade to) {
|
||||
if (evidence == null || from == null || to == null) {
|
||||
throw new IllegalArgumentException("a promotion needs evidence and both grades");
|
||||
}
|
||||
if (from == to) {
|
||||
throw new IllegalArgumentException("a promotion changes the grade");
|
||||
}
|
||||
GrpcAdvancedCapability capability = evidence.capability();
|
||||
List<String> blockers = new ArrayList<>();
|
||||
|
||||
evidence.missing().stream()
|
||||
.sorted()
|
||||
.forEach(missing -> blockers.add(capability.flagName() + " has no " + missing));
|
||||
|
||||
Duration requiredSoak =
|
||||
to == GrpcCapabilityGrade.ADVANCED_STABLE ? ADVANCED_STABLE_SOAK : STABLE_DEFAULT_SOAK;
|
||||
if (evidence.soakDuration().compareTo(requiredSoak) < 0) {
|
||||
blockers.add(
|
||||
capability.flagName()
|
||||
+ " soaked for "
|
||||
+ evidence.soakDuration().toDays()
|
||||
+ " day(s); promotion to "
|
||||
+ to
|
||||
+ " requires "
|
||||
+ requiredSoak.toDays());
|
||||
}
|
||||
if (from == GrpcCapabilityGrade.WATCH && to != GrpcCapabilityGrade.EXPERIMENTAL) {
|
||||
blockers.add(
|
||||
capability.flagName()
|
||||
+ " is WATCH, which is tracked rather than implemented; it becomes EXPERIMENTAL "
|
||||
+ "before anything else");
|
||||
}
|
||||
return blockers.isEmpty()
|
||||
? GrpcAdvancedPromotionDecision.grant(capability, from, to)
|
||||
: GrpcAdvancedPromotionDecision.refuse(capability, from, to, blockers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether promoting {@code promoted} would drag another capability with it.
|
||||
*
|
||||
* <p>Always empty, and the method exists so a test can assert that rather than a comment claiming
|
||||
* it: each capability's evidence is its own record, so there is no path by which one promotion
|
||||
* changes another's grade.
|
||||
*/
|
||||
public static List<GrpcAdvancedCapability> capabilitiesDraggedAlong(
|
||||
GrpcAdvancedCapability promoted) {
|
||||
if (promoted == null) {
|
||||
throw new IllegalArgumentException("a capability is required");
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package dev.caskeleton.grpc.advanced.release;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcCapabilityGrade;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Every advanced capability's current grade, in one place an adopter can read.
|
||||
*
|
||||
* <p>The published answer to "is this supported". Without it the answer is inferred from whether a
|
||||
* class exists, which says only that somebody wrote it.
|
||||
*/
|
||||
public final class GrpcAdvancedSupportMatrix {
|
||||
|
||||
private final Map<GrpcAdvancedCapability, GrpcCapabilityGrade> grades =
|
||||
new EnumMap<>(GrpcAdvancedCapability.class);
|
||||
|
||||
/** A matrix at each capability's default grade. */
|
||||
public GrpcAdvancedSupportMatrix() {
|
||||
for (GrpcAdvancedCapability capability : GrpcAdvancedCapability.values()) {
|
||||
grades.put(capability, capability.defaultGrade());
|
||||
}
|
||||
}
|
||||
|
||||
/** The grade of one capability. */
|
||||
public GrpcCapabilityGrade gradeOf(GrpcAdvancedCapability capability) {
|
||||
if (capability == null) {
|
||||
throw new IllegalArgumentException("a capability is required");
|
||||
}
|
||||
return grades.get(capability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a granted promotion.
|
||||
*
|
||||
* @throws IllegalArgumentException when the decision's starting grade is not the current one,
|
||||
* which means two promotions raced or one was replayed
|
||||
*/
|
||||
public GrpcAdvancedSupportMatrix apply(GrpcAdvancedPromotionDecision decision) {
|
||||
if (decision == null) {
|
||||
throw new IllegalArgumentException("a decision is required");
|
||||
}
|
||||
if (!decision.promoted()) {
|
||||
return this;
|
||||
}
|
||||
GrpcCapabilityGrade current = grades.get(decision.capability());
|
||||
if (current != decision.from()) {
|
||||
throw new IllegalArgumentException(
|
||||
"capability '"
|
||||
+ decision.capability().flagName()
|
||||
+ "' is "
|
||||
+ current
|
||||
+ ", not "
|
||||
+ decision.from()
|
||||
+ "; this decision was made against a different matrix");
|
||||
}
|
||||
grades.put(decision.capability(), decision.to());
|
||||
return this;
|
||||
}
|
||||
|
||||
/** The whole matrix. */
|
||||
public Map<GrpcAdvancedCapability, GrpcCapabilityGrade> snapshot() {
|
||||
return Map.copyOf(grades);
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package dev.caskeleton.grpc.advanced.bootstrap;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcAdvancedModuleGuardTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("every advanced capability is off unless a deployment names it")
|
||||
void everythingIsOffByDefault() {
|
||||
GrpcAdvancedFeatureFlags flags = GrpcAdvancedFeatureFlags.forDevelopment();
|
||||
|
||||
assertThat(flags.active()).isEmpty();
|
||||
for (GrpcAdvancedCapability capability : GrpcAdvancedCapability.values()) {
|
||||
assertThat(GrpcAdvancedModuleGuard.available(flags, capability)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("each capability has its own flag, not a shared one")
|
||||
void eachCapabilityHasItsOwnFlag() {
|
||||
assertThat(GrpcAdvancedCapability.XDS.propertyKey())
|
||||
.isEqualTo("ca-skeleton.grpc.advanced.xds.enabled");
|
||||
assertThat(GrpcAdvancedCapability.GRPC_WEB.propertyKey())
|
||||
.isNotEqualTo(GrpcAdvancedCapability.XDS.propertyKey());
|
||||
|
||||
GrpcAdvancedFeatureFlags flags =
|
||||
GrpcAdvancedFeatureFlags.forDevelopment().enable(GrpcAdvancedCapability.GRPC_WEB);
|
||||
|
||||
assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.GRPC_WEB)).isTrue();
|
||||
assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.XDS)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unflagged capability is refused, and the message names the key to set")
|
||||
void anUnflaggedCapabilityNamesItsKey() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
GrpcAdvancedModuleGuard.require(
|
||||
GrpcAdvancedFeatureFlags.forDevelopment(), GrpcAdvancedCapability.REACTOR))
|
||||
.isInstanceOf(GrpcAdvancedCapabilityDisabledException.class)
|
||||
.hasMessageContaining("ca-skeleton.grpc.advanced.reactor.enabled=true");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a WATCH capability cannot be enabled, however loudly it is flagged")
|
||||
void aWatchCapabilityCannotStart() {
|
||||
GrpcAdvancedFeatureFlags flags =
|
||||
GrpcAdvancedFeatureFlags.forDevelopment().enable(GrpcAdvancedCapability.EDITION_2026);
|
||||
|
||||
assertThat(GrpcAdvancedCapability.EDITION_2026.defaultGrade())
|
||||
.isEqualTo(GrpcCapabilityGrade.WATCH);
|
||||
assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.EDITION_2026))
|
||||
.isFalse();
|
||||
assertThatThrownBy(
|
||||
() -> GrpcAdvancedModuleGuard.require(flags, GrpcAdvancedCapability.EDITION_2026))
|
||||
.isInstanceOf(GrpcAdvancedCapabilityDisabledException.class)
|
||||
.hasMessageContaining("tracked rather than implemented");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an experimental capability needs a second approval in production")
|
||||
void experimentalCapabilitiesNeedAProductionApproval() {
|
||||
GrpcAdvancedFeatureFlags unapproved =
|
||||
GrpcAdvancedFeatureFlags.forProduction(Set.of()).enable(GrpcAdvancedCapability.XDS);
|
||||
GrpcAdvancedFeatureFlags approved =
|
||||
GrpcAdvancedFeatureFlags.forProduction(Set.of(GrpcAdvancedCapability.XDS))
|
||||
.enable(GrpcAdvancedCapability.XDS);
|
||||
|
||||
assertThat(GrpcAdvancedModuleGuard.available(unapproved, GrpcAdvancedCapability.XDS)).isFalse();
|
||||
assertThat(GrpcAdvancedModuleGuard.available(approved, GrpcAdvancedCapability.XDS)).isTrue();
|
||||
assertThatThrownBy(
|
||||
() -> GrpcAdvancedModuleGuard.require(unapproved, GrpcAdvancedCapability.XDS))
|
||||
.isInstanceOf(GrpcAdvancedCapabilityDisabledException.class)
|
||||
.hasMessageContaining("uncharacterised failure modes");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an experimental capability runs outside production on its flag alone")
|
||||
void experimentalCapabilitiesRunInDevelopment() {
|
||||
GrpcAdvancedFeatureFlags flags =
|
||||
GrpcAdvancedFeatureFlags.forDevelopment().enable(GrpcAdvancedCapability.HEDGING);
|
||||
|
||||
assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.HEDGING)).isTrue();
|
||||
GrpcAdvancedModuleGuard.require(flags, GrpcAdvancedCapability.HEDGING);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the Stable starter reaching an advanced module is refused")
|
||||
void theStableStarterMayNotReachAnAdvancedModule() {
|
||||
GrpcAdvancedModuleGuard.requireStableStarterIsClean(Set.of("grpc-core-api", "grpc-policy"));
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
GrpcAdvancedModuleGuard.requireStableStarterIsClean(
|
||||
Set.of("grpc-core-api", "grpc-advanced-resilience")))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("grpc-advanced-resilience");
|
||||
assertThat(GrpcAdvancedModuleGuard.advancedModules())
|
||||
.contains("grpc-advanced-bootstrap", "grpc-advanced-streaming", "grpc-advanced-compat");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("only flagged and permitted capabilities appear as active")
|
||||
void activeReflectsBothFlagAndGrade() {
|
||||
GrpcAdvancedFeatureFlags flags =
|
||||
GrpcAdvancedFeatureFlags.forProduction(Set.of())
|
||||
.enable(GrpcAdvancedCapability.GRPC_WEB)
|
||||
.enable(GrpcAdvancedCapability.XDS)
|
||||
.enable(GrpcAdvancedCapability.EDITION_2026);
|
||||
|
||||
assertThat(flags.active()).containsExactly(GrpcAdvancedCapability.GRPC_WEB);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a deployment may raise a capability's grade on its own evidence")
|
||||
void aDeploymentMayOverrideAGrade() {
|
||||
GrpcAdvancedFeatureFlags flags =
|
||||
GrpcAdvancedFeatureFlags.forProduction(Set.of())
|
||||
.enable(GrpcAdvancedCapability.HEDGING)
|
||||
.withGrade(GrpcAdvancedCapability.HEDGING, GrpcCapabilityGrade.ADVANCED_STABLE);
|
||||
|
||||
assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.HEDGING)).isTrue();
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package dev.caskeleton.grpc.advanced.release;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcCapabilityGrade;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcAdvancedPromotionGateTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("a capability with complete evidence and a full soak is promoted")
|
||||
void completeEvidencePromotes() {
|
||||
GrpcAdvancedPromotionDecision decision =
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
GrpcAdvancedPromotionEvidence.complete(
|
||||
GrpcAdvancedCapability.HEDGING, Duration.ofDays(7)),
|
||||
GrpcCapabilityGrade.EXPERIMENTAL,
|
||||
GrpcCapabilityGrade.ADVANCED_STABLE);
|
||||
|
||||
assertThat(decision.promoted()).isTrue();
|
||||
assertThat(decision.blockers()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("every missing piece of evidence is named")
|
||||
void everyMissingPieceIsNamed() {
|
||||
GrpcAdvancedPromotionDecision decision =
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
GrpcAdvancedPromotionEvidence.none(GrpcAdvancedCapability.XDS),
|
||||
GrpcCapabilityGrade.EXPERIMENTAL,
|
||||
GrpcCapabilityGrade.ADVANCED_STABLE);
|
||||
|
||||
assertThat(decision.blockers())
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("compatibility evidence"))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("security review"))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("fault evidence"))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("runbook"))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("real environment test"))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("soaked for"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("becoming a Stable default needs a longer soak than becoming Advanced Stable")
|
||||
void theStableDefaultThresholdIsHigher() {
|
||||
GrpcAdvancedPromotionEvidence weekLongSoak =
|
||||
GrpcAdvancedPromotionEvidence.complete(GrpcAdvancedCapability.GRPC_WEB, Duration.ofDays(7));
|
||||
|
||||
assertThat(
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
weekLongSoak,
|
||||
GrpcCapabilityGrade.EXPERIMENTAL,
|
||||
GrpcCapabilityGrade.ADVANCED_STABLE)
|
||||
.promoted())
|
||||
.isTrue();
|
||||
assertThat(
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
weekLongSoak, GrpcCapabilityGrade.ADVANCED_STABLE, GrpcCapabilityGrade.DISABLED)
|
||||
.blockers())
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("requires 30"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a WATCH capability becomes EXPERIMENTAL before anything else")
|
||||
void watchPromotesOnlyToExperimental() {
|
||||
assertThat(
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
GrpcAdvancedPromotionEvidence.complete(
|
||||
GrpcAdvancedCapability.EDITION_2026, Duration.ofDays(60)),
|
||||
GrpcCapabilityGrade.WATCH,
|
||||
GrpcCapabilityGrade.ADVANCED_STABLE)
|
||||
.blockers())
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("becomes EXPERIMENTAL"));
|
||||
assertThat(
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
GrpcAdvancedPromotionEvidence.complete(
|
||||
GrpcAdvancedCapability.EDITION_2026, Duration.ofDays(60)),
|
||||
GrpcCapabilityGrade.WATCH,
|
||||
GrpcCapabilityGrade.EXPERIMENTAL)
|
||||
.promoted())
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("promoting one capability drags none of the others with it")
|
||||
void promotionsAreIndependent() {
|
||||
GrpcAdvancedSupportMatrix matrix = new GrpcAdvancedSupportMatrix();
|
||||
GrpcCapabilityGrade webBefore = matrix.gradeOf(GrpcAdvancedCapability.GRPC_WEB);
|
||||
|
||||
matrix.apply(
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
GrpcAdvancedPromotionEvidence.complete(
|
||||
GrpcAdvancedCapability.HEDGING, Duration.ofDays(7)),
|
||||
GrpcCapabilityGrade.EXPERIMENTAL,
|
||||
GrpcCapabilityGrade.ADVANCED_STABLE));
|
||||
|
||||
assertThat(matrix.gradeOf(GrpcAdvancedCapability.HEDGING))
|
||||
.isEqualTo(GrpcCapabilityGrade.ADVANCED_STABLE);
|
||||
assertThat(matrix.gradeOf(GrpcAdvancedCapability.GRPC_WEB)).isEqualTo(webBefore);
|
||||
assertThat(GrpcAdvancedPromotionGate.capabilitiesDraggedAlong(GrpcAdvancedCapability.HEDGING))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a decision made against a different matrix state is refused")
|
||||
void aStaleDecisionIsRefused() {
|
||||
GrpcAdvancedSupportMatrix matrix = new GrpcAdvancedSupportMatrix();
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
matrix.apply(
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
GrpcAdvancedPromotionEvidence.complete(
|
||||
GrpcAdvancedCapability.GRPC_WEB, Duration.ofDays(7)),
|
||||
GrpcCapabilityGrade.EXPERIMENTAL,
|
||||
GrpcCapabilityGrade.ADVANCED_STABLE)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("made against a different matrix");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a refused promotion leaves the matrix alone")
|
||||
void aRefusedPromotionChangesNothing() {
|
||||
GrpcAdvancedSupportMatrix matrix = new GrpcAdvancedSupportMatrix();
|
||||
|
||||
matrix.apply(
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
GrpcAdvancedPromotionEvidence.none(GrpcAdvancedCapability.XDS),
|
||||
GrpcCapabilityGrade.EXPERIMENTAL,
|
||||
GrpcCapabilityGrade.ADVANCED_STABLE));
|
||||
|
||||
assertThat(matrix.gradeOf(GrpcAdvancedCapability.XDS))
|
||||
.isEqualTo(GrpcCapabilityGrade.EXPERIMENTAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a promotion that changes nothing is refused")
|
||||
void aNoOpPromotionIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
GrpcAdvancedPromotionEvidence.none(GrpcAdvancedCapability.XDS),
|
||||
GrpcCapabilityGrade.EXPERIMENTAL,
|
||||
GrpcCapabilityGrade.EXPERIMENTAL))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
// Compatibility bridges: gRPC-Web, the Servlet HTTP/2 profile, the Spring Integration bridge, the
|
||||
// Reactor adapter, and the Kotlin coroutine/Flow boundary.
|
||||
//
|
||||
// No Kotlin source set (adaptation D7): this repository has no Kotlin toolchain, so the Kotlin lane
|
||||
// is expressed as a Java-side boundary contract whose compatibility gate fails closed until a real
|
||||
// toolchain lane exists. Everything the gate would otherwise assert — one schema source, coroutine
|
||||
// cancellation propagation, Flow backpressure inside the Stable buffer limits, evidence type
|
||||
// preservation — is a checkable contract without it.
|
||||
dependencies {
|
||||
api project(':grpc:grpc-core-api')
|
||||
api project(':grpc:grpc-policy')
|
||||
api project(':grpc:grpc-server')
|
||||
api project(':grpc:grpc-client')
|
||||
api project(':grpc-advanced:grpc-advanced-bootstrap')
|
||||
|
||||
// api: the Reactor adapter's public signatures are Mono/Flux, and the Integration gateways name
|
||||
// Spring Integration's Message. Hiding either would only stop an adopter compiling against the
|
||||
// API this module documents.
|
||||
api 'io.projectreactor:reactor-core'
|
||||
api 'org.springframework.integration:spring-integration-core'
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.6=spotbugs
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.5=spotbugs
|
||||
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
|
||||
org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.18=checkstyle
|
||||
org.springframework.integration:spring-integration-core:7.0.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-messaging:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-tx:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
empty=
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package dev.caskeleton.grpc.advanced.integration;
|
||||
|
||||
import dev.caskeleton.grpc.context.GrpcMetadataKey;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What a Spring Integration flow may exchange with a gRPC call.
|
||||
*
|
||||
* <p>The header allowlist is the whole policy. A Spring Integration {@code Message} accumulates
|
||||
* headers as it moves through a flow — routing keys, correlation ids, errors channels, whatever a
|
||||
* transformer added — and copying them onto gRPC metadata sends a service's internal plumbing
|
||||
* across the network, where it counts against the metadata budget and occasionally carries
|
||||
* something sensitive.
|
||||
*
|
||||
* <p>The bridge does not add durability. Spring Integration channels can look like a broker, and a
|
||||
* bridge that implied acknowledgement or redelivery semantics would be promising something gRPC
|
||||
* does not do.
|
||||
*/
|
||||
public record GrpcIntegrationBridgePolicy(
|
||||
Set<GrpcMetadataKey> headerAllowlist, Set<String> payloadConverters) {
|
||||
|
||||
/** Copies both sets and refuses a bridge with no converter. */
|
||||
public GrpcIntegrationBridgePolicy {
|
||||
if (headerAllowlist == null || payloadConverters == null) {
|
||||
throw new IllegalArgumentException("a bridge policy states its allowlist and converters");
|
||||
}
|
||||
headerAllowlist = Set.copyOf(headerAllowlist);
|
||||
payloadConverters = Set.copyOf(payloadConverters);
|
||||
if (payloadConverters.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a bridge with no registered converter cannot turn a Message payload into a request; "
|
||||
+ "leaving it to reflection is how an unexpected type reaches the wire");
|
||||
}
|
||||
}
|
||||
|
||||
/** The metadata that survives from a Message's headers. */
|
||||
public Map<GrpcMetadataKey, String> metadataFrom(Map<String, Object> messageHeaders) {
|
||||
if (messageHeaders == null) {
|
||||
throw new IllegalArgumentException("message headers must not be null");
|
||||
}
|
||||
Map<GrpcMetadataKey, String> metadata = new LinkedHashMap<>();
|
||||
headerAllowlist.forEach(
|
||||
key -> {
|
||||
Object value = messageHeaders.get(key.name());
|
||||
if (value != null) {
|
||||
metadata.put(key, String.valueOf(value));
|
||||
}
|
||||
});
|
||||
return Map.copyOf(metadata);
|
||||
}
|
||||
|
||||
/** Whether a payload type has a registered converter. */
|
||||
public boolean converterRegistered(String payloadType) {
|
||||
return payloadConverters.contains(payloadType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the bridge provides broker-style acknowledgement or redelivery.
|
||||
*
|
||||
* <p>Always false. A bridge that implied either would be promising a delivery guarantee gRPC does
|
||||
* not make.
|
||||
*/
|
||||
public boolean providesBrokerSemantics() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the bridge replaces the generated stub and service APIs.
|
||||
*
|
||||
* <p>Always false. It is one way to reach a gRPC call from an existing integration flow, not the
|
||||
* way an application is meant to call one.
|
||||
*/
|
||||
public boolean replacesGeneratedApis() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package dev.caskeleton.grpc.advanced.integration;
|
||||
|
||||
import dev.caskeleton.grpc.context.GrpcRequestContext;
|
||||
import java.util.Map;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.support.MessageBuilder;
|
||||
|
||||
/**
|
||||
* Turns an inbound gRPC call into a Spring Integration {@code Message}.
|
||||
*
|
||||
* <p>The request context travels as one header holding the immutable context object, rather than as
|
||||
* a scattering of actor, tenant and deadline headers. Flattening it would let a transformer in the
|
||||
* middle of a flow change the tenant of a request that has already been authenticated.
|
||||
*
|
||||
* @param <Q> the request payload type
|
||||
*/
|
||||
public final class GrpcIntegrationInboundGateway<Q> {
|
||||
|
||||
/** The header the immutable request context travels under. */
|
||||
public static final String CONTEXT_HEADER = "grpcRequestContext";
|
||||
|
||||
private final GrpcIntegrationBridgePolicy policy;
|
||||
|
||||
/** Binds a gateway to its bridge policy. */
|
||||
public GrpcIntegrationInboundGateway(GrpcIntegrationBridgePolicy policy) {
|
||||
if (policy == null) {
|
||||
throw new IllegalArgumentException("an inbound gateway needs a bridge policy");
|
||||
}
|
||||
this.policy = policy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Message a flow receives.
|
||||
*
|
||||
* @throws IllegalArgumentException when the payload type has no registered converter
|
||||
*/
|
||||
public Message<Q> toMessage(Q payload, GrpcRequestContext context) {
|
||||
if (payload == null || context == null) {
|
||||
throw new IllegalArgumentException("an inbound message needs a payload and a context");
|
||||
}
|
||||
String payloadType = payload.getClass().getName();
|
||||
if (!policy.converterRegistered(payloadType)) {
|
||||
throw new IllegalArgumentException(
|
||||
"no converter is registered for '"
|
||||
+ payloadType
|
||||
+ "'; converting by reflection is how an unexpected type reaches a flow");
|
||||
}
|
||||
Map<String, Object> headers = new java.util.LinkedHashMap<>();
|
||||
headers.put(CONTEXT_HEADER, context);
|
||||
context.metadata().forEach((key, value) -> headers.put(key.name(), value));
|
||||
return MessageBuilder.withPayload(payload).copyHeaders(headers).build();
|
||||
}
|
||||
|
||||
/** The policy in force. */
|
||||
public GrpcIntegrationBridgePolicy policy() {
|
||||
return policy;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.grpc.advanced.integration;
|
||||
|
||||
import dev.caskeleton.grpc.context.GrpcMetadataKey;
|
||||
import java.util.Map;
|
||||
import org.springframework.messaging.Message;
|
||||
|
||||
/**
|
||||
* Turns an outbound Spring Integration {@code Message} into a gRPC call's inputs.
|
||||
*
|
||||
* <p>Applies the header allowlist rather than copying what the flow accumulated. A flow's headers
|
||||
* are its own bookkeeping; putting them on the wire spends the metadata budget on another service's
|
||||
* plumbing and occasionally sends something that should not leave the process.
|
||||
*
|
||||
* @param <C> the request payload type
|
||||
*/
|
||||
public final class GrpcIntegrationOutboundGateway<C> {
|
||||
|
||||
private final GrpcIntegrationBridgePolicy policy;
|
||||
|
||||
/** Binds a gateway to its bridge policy. */
|
||||
public GrpcIntegrationOutboundGateway(GrpcIntegrationBridgePolicy policy) {
|
||||
if (policy == null) {
|
||||
throw new IllegalArgumentException("an outbound gateway needs a bridge policy");
|
||||
}
|
||||
this.policy = policy;
|
||||
}
|
||||
|
||||
/** The metadata this message contributes to the call. */
|
||||
public Map<GrpcMetadataKey, String> metadataFrom(Message<C> message) {
|
||||
if (message == null) {
|
||||
throw new IllegalArgumentException("an outbound message is required");
|
||||
}
|
||||
return policy.metadataFrom(message.getHeaders());
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload to send.
|
||||
*
|
||||
* @throws IllegalArgumentException when the payload type has no registered converter
|
||||
*/
|
||||
public C payloadFrom(Message<C> message) {
|
||||
if (message == null) {
|
||||
throw new IllegalArgumentException("an outbound message is required");
|
||||
}
|
||||
C payload = message.getPayload();
|
||||
String payloadType = payload.getClass().getName();
|
||||
if (!policy.converterRegistered(payloadType)) {
|
||||
throw new IllegalArgumentException("no converter is registered for '" + payloadType + "'");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** The policy in force. */
|
||||
public GrpcIntegrationBridgePolicy policy() {
|
||||
return policy;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package dev.caskeleton.grpc.advanced.kotlin;
|
||||
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationCoordinator;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationReason;
|
||||
import java.time.Instant;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* The contract a Kotlin coroutine adapter has to satisfy, expressed as Java callbacks.
|
||||
*
|
||||
* <p>Callbacks rather than coroutine types, so the rule is checkable without a Kotlin toolchain. A
|
||||
* Kotlin adapter wires its {@code Job} completion handler to {@link #onCoroutineCancelled} and its
|
||||
* cancellation source to {@link #cancelCoroutineScope}; what the platform needs is that both
|
||||
* directions exist, and that is what this makes assertable.
|
||||
*/
|
||||
public final class GrpcCoroutineContextBridge {
|
||||
|
||||
private GrpcCoroutineContextBridge() {}
|
||||
|
||||
/**
|
||||
* What a Kotlin adapter calls when its coroutine scope is cancelled.
|
||||
*
|
||||
* <p>Coroutine cancellation is cooperative and structured: cancelling a scope cancels its
|
||||
* children, and a gRPC call started inside it is not one of them unless something says so.
|
||||
*/
|
||||
public static Runnable onCoroutineCancelled(
|
||||
GrpcCancellationCoordinator coordinator, Supplier<Instant> now) {
|
||||
if (coordinator == null || now == null) {
|
||||
throw new IllegalArgumentException("the bridge needs a coordinator and a clock");
|
||||
}
|
||||
return () -> coordinator.cancel(GrpcCancellationReason.CLIENT_CANCELLED, now.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the coroutine scope so a platform cancellation reaches it.
|
||||
*
|
||||
* @param cancelScope what the Kotlin side does to cancel its scope
|
||||
*/
|
||||
public static void cancelCoroutineScope(
|
||||
GrpcCancellationCoordinator coordinator, Runnable cancelScope, String operationName) {
|
||||
if (coordinator == null || cancelScope == null) {
|
||||
throw new IllegalArgumentException("the bridge needs a coordinator and a cancel action");
|
||||
}
|
||||
if (operationName == null || operationName.isBlank()) {
|
||||
throw new IllegalArgumentException("a cancellable operation needs a name");
|
||||
}
|
||||
coordinator.register(
|
||||
new dev.caskeleton.grpc.deadline.GrpcCancellableOperation() {
|
||||
@Override
|
||||
public String name() {
|
||||
return operationName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancel(GrpcCancellationReason reason) {
|
||||
cancelScope.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package dev.caskeleton.grpc.advanced.kotlin;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Whether a Kotlin adapter may be advertised as supported.
|
||||
*
|
||||
* <p>Fails closed in this repository, and says so rather than reporting a pass it cannot justify.
|
||||
* There is no Kotlin toolchain here (adaptation design D7), so the compile lane that would
|
||||
* establish the last requirement has never run; a gate that reported success anyway would put an
|
||||
* unverified claim in the support matrix.
|
||||
*
|
||||
* <p>The other four requirements are checkable and are checked, so turning the toolchain on later
|
||||
* is a lane to add rather than a contract to write.
|
||||
*/
|
||||
public final class GrpcKotlinCompatibilityGate {
|
||||
|
||||
private GrpcKotlinCompatibilityGate() {}
|
||||
|
||||
/**
|
||||
* Every reason a Kotlin adapter is not yet supportable.
|
||||
*
|
||||
* @param toolchainLaneRan whether a Kotlin compile lane actually ran against this profile
|
||||
* @return an empty list only when the profile is complete and its lane has run
|
||||
*/
|
||||
public static List<String> blockers(GrpcKotlinProfile profile, boolean toolchainLaneRan) {
|
||||
if (profile == null) {
|
||||
throw new IllegalArgumentException("a Kotlin profile is required");
|
||||
}
|
||||
List<String> blockers = new ArrayList<>();
|
||||
if (!profile.sharesSchemaSourceWithJava()) {
|
||||
blockers.add(
|
||||
"the Kotlin contract does not share one schema source with the Java contract; two "
|
||||
+ "sources diverge where only somebody reading both would notice");
|
||||
}
|
||||
if (!profile.propagatesCoroutineCancellation()) {
|
||||
blockers.add(
|
||||
"coroutine cancellation does not reach the gRPC call; a cancelled scope would leave the "
|
||||
+ "call running");
|
||||
}
|
||||
if (!profile.respectsStableFlowControl()) {
|
||||
blockers.add(
|
||||
"Flow backpressure bypasses the Stable buffer bounds; a slow collector would buffer "
|
||||
+ "without a limit rather than terminating the stream");
|
||||
}
|
||||
if (!profile.preservesPlatformEvidenceTypes()) {
|
||||
blockers.add(
|
||||
"the adapter does not preserve the platform's evidence, status and deadline types; a "
|
||||
+ "Kotlin-idiomatic re-creation is a second model of the same facts");
|
||||
}
|
||||
if (!toolchainLaneRan) {
|
||||
blockers.add(
|
||||
"no Kotlin toolchain lane has run against "
|
||||
+ profile.kotlinToolchainVersion()
|
||||
+ "; this repository has no Kotlin toolchain, so the compile evidence does not exist");
|
||||
}
|
||||
return List.copyOf(blockers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Kotlin adapter is supportable in this repository as it stands.
|
||||
*
|
||||
* <p>False. The four contract requirements can be satisfied; the toolchain lane cannot.
|
||||
*/
|
||||
public static boolean supportableHere() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.grpc.advanced.kotlin;
|
||||
|
||||
/**
|
||||
* How a Kotlin adapter must behave, stated from the Java side.
|
||||
*
|
||||
* <p>Java-side because this repository has no Kotlin toolchain (adaptation design D7). What can be
|
||||
* expressed without one is every rule that constrains the adapter: one schema source shared with
|
||||
* the Java contract, coroutine cancellation propagated to the call, Flow backpressure inside the
|
||||
* Stable buffer bounds, and the platform's evidence and status types preserved rather than
|
||||
* re-created in Kotlin idiom.
|
||||
*
|
||||
* <p>Each is a component so the gate can check them individually. A single "compatible" flag would
|
||||
* let a partially compliant adapter through, and the most likely partial failure — a Flow that
|
||||
* buffers without a bound — is the one with the worst production behaviour.
|
||||
*/
|
||||
public record GrpcKotlinProfile(
|
||||
boolean sharesSchemaSourceWithJava,
|
||||
boolean propagatesCoroutineCancellation,
|
||||
boolean respectsStableFlowControl,
|
||||
boolean preservesPlatformEvidenceTypes,
|
||||
String kotlinToolchainVersion) {
|
||||
|
||||
/** Refuses a profile that claims support without naming a toolchain. */
|
||||
public GrpcKotlinProfile {
|
||||
if (kotlinToolchainVersion == null || kotlinToolchainVersion.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a Kotlin profile names the toolchain it was verified against; 'Kotlin' is not a version");
|
||||
}
|
||||
}
|
||||
|
||||
/** A profile for a toolchain this repository has not verified. */
|
||||
public static GrpcKotlinProfile unverified(String kotlinToolchainVersion) {
|
||||
return new GrpcKotlinProfile(false, false, false, false, kotlinToolchainVersion);
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package dev.caskeleton.grpc.advanced.reactor;
|
||||
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationCoordinator;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationReason;
|
||||
import java.time.Instant;
|
||||
import java.util.function.Supplier;
|
||||
import reactor.core.Disposable;
|
||||
|
||||
/**
|
||||
* Turns a Reactor subscription's cancellation into the platform's.
|
||||
*
|
||||
* <p>Both directions, and both are needed. A client that disposes its {@code Mono} has stopped
|
||||
* caring, and without this bridge the server keeps computing an answer nobody will read; a call the
|
||||
* platform cancelled — deadline, drain, revoked credential — has to stop the reactive pipeline, or
|
||||
* the work continues after the response has been closed.
|
||||
*/
|
||||
public final class GrpcReactorCancellationBridge {
|
||||
|
||||
private GrpcReactorCancellationBridge() {}
|
||||
|
||||
/**
|
||||
* A callback for {@code doOnCancel} that cancels the platform call.
|
||||
*
|
||||
* @param now supplies the moment, so a test does not depend on the wall clock
|
||||
*/
|
||||
public static Runnable onReactorCancel(
|
||||
GrpcCancellationCoordinator coordinator, Supplier<Instant> now) {
|
||||
if (coordinator == null || now == null) {
|
||||
throw new IllegalArgumentException("a cancellation bridge needs a coordinator and a clock");
|
||||
}
|
||||
return () -> coordinator.cancel(GrpcCancellationReason.CLIENT_CANCELLED, now.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes the reactive pipeline when the platform cancels.
|
||||
*
|
||||
* <p>Registered as a cancellable operation, so it is reached by the same cancellation that stops
|
||||
* the database query and the stream writer rather than by a second mechanism.
|
||||
*/
|
||||
public static void bindPlatformCancellation(
|
||||
GrpcCancellationCoordinator coordinator, Disposable subscription, String operationName) {
|
||||
if (coordinator == null || subscription == null) {
|
||||
throw new IllegalArgumentException("binding needs a coordinator and a subscription");
|
||||
}
|
||||
if (operationName == null || operationName.isBlank()) {
|
||||
throw new IllegalArgumentException("a cancellable operation needs a name");
|
||||
}
|
||||
coordinator.register(
|
||||
new dev.caskeleton.grpc.deadline.GrpcCancellableOperation() {
|
||||
@Override
|
||||
public String name() {
|
||||
return operationName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancel(GrpcCancellationReason reason) {
|
||||
subscription.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package dev.caskeleton.grpc.advanced.reactor;
|
||||
|
||||
import dev.caskeleton.grpc.context.GrpcContextSnapshot;
|
||||
import java.util.Optional;
|
||||
import reactor.util.context.Context;
|
||||
import reactor.util.context.ContextView;
|
||||
|
||||
/**
|
||||
* Carries the call context between gRPC's {@code Context} and Reactor's.
|
||||
*
|
||||
* <p>Explicitly, because neither propagates into the other. gRPC's context is a thread-local
|
||||
* mechanism and Reactor's travels with the subscription, so an operator that hops threads leaves
|
||||
* the gRPC context behind and a gRPC interceptor cannot see the Reactor one. Work downstream of
|
||||
* that boundary then runs with no actor, no tenant and no deadline, which is the failure that
|
||||
* produces a change attributed to nobody.
|
||||
*/
|
||||
public final class GrpcReactorContextBridge {
|
||||
|
||||
private static final String CONTEXT_KEY = "dev.caskeleton.grpc.contextSnapshot";
|
||||
|
||||
private GrpcReactorContextBridge() {}
|
||||
|
||||
/** Puts the snapshot into a Reactor context. */
|
||||
public static Context write(Context context, GrpcContextSnapshot snapshot) {
|
||||
if (context == null || snapshot == null) {
|
||||
throw new IllegalArgumentException("bridging needs a Reactor context and a snapshot");
|
||||
}
|
||||
return context.put(CONTEXT_KEY, snapshot);
|
||||
}
|
||||
|
||||
/** Reads the snapshot out of a Reactor context, if it is there. */
|
||||
public static Optional<GrpcContextSnapshot> read(ContextView context) {
|
||||
if (context == null) {
|
||||
throw new IllegalArgumentException("a Reactor context view is required");
|
||||
}
|
||||
return context.hasKey(CONTEXT_KEY) ? Optional.of(context.get(CONTEXT_KEY)) : Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the snapshot, failing when it is absent.
|
||||
*
|
||||
* @throws IllegalStateException because reactive work with no call context has no actor, no
|
||||
* tenant and no deadline
|
||||
*/
|
||||
public static GrpcContextSnapshot require(ContextView context) {
|
||||
return read(context)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"no gRPC call context is in the Reactor context; reactive work that runs "
|
||||
+ "without one produces a change attributed to nobody"));
|
||||
}
|
||||
|
||||
/** The key the snapshot travels under, for a test or a diagnostic to look at. */
|
||||
public static String contextKey() {
|
||||
return CONTEXT_KEY;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.grpc.advanced.reactor;
|
||||
|
||||
import dev.caskeleton.grpc.context.GrpcContextSnapshot;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Exposes a unary call as a {@code Mono} and a server stream as a {@code Flux}.
|
||||
*
|
||||
* <p>Only here. The Stable contract types stay free of Reactor, so a deployment that does not use
|
||||
* it does not carry it, and the reactive shape is a view over the platform rather than the
|
||||
* platform's own vocabulary.
|
||||
*
|
||||
* <p>The {@code Flux} respects the Stable bounded flow control rather than replacing it. Reactor's
|
||||
* backpressure and gRPC's are two mechanisms over one connection; letting a subscriber's {@code
|
||||
* request(n)} drive the writer directly would bypass the bounded queue that decides what happens
|
||||
* when a consumer falls behind.
|
||||
*/
|
||||
public final class ReactiveGrpcClient {
|
||||
|
||||
private ReactiveGrpcClient() {}
|
||||
|
||||
/**
|
||||
* A unary call as a {@code Mono}, with the call context carried in the Reactor context.
|
||||
*
|
||||
* @param call the blocking invocation, run on {@code boundedElasticScheduler} rather than on the
|
||||
* calling thread. A blocking call on an event loop stalls every other call sharing it, and
|
||||
* the mistake is invisible until load arrives.
|
||||
*/
|
||||
public static <R> Mono<R> unary(
|
||||
Supplier<R> call, GrpcContextSnapshot snapshot, reactor.core.scheduler.Scheduler blocking) {
|
||||
if (call == null || snapshot == null || blocking == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"a reactive unary call needs an invocation, a context and a scheduler");
|
||||
}
|
||||
return Mono.fromSupplier(call)
|
||||
.subscribeOn(blocking)
|
||||
.contextWrite(context -> GrpcReactorContextBridge.write(context, snapshot));
|
||||
}
|
||||
|
||||
/**
|
||||
* A server stream as a {@code Flux}.
|
||||
*
|
||||
* @param messages the already-bounded message source. Taking a list rather than a producer is
|
||||
* deliberate: the bound belongs to the Stable stream writer, and a producer here would be a
|
||||
* second place to get it wrong.
|
||||
*/
|
||||
public static <R> Flux<R> serverStream(List<R> messages, GrpcContextSnapshot snapshot) {
|
||||
if (messages == null || snapshot == null) {
|
||||
throw new IllegalArgumentException("a reactive stream needs its messages and a context");
|
||||
}
|
||||
return Flux.fromIterable(messages)
|
||||
.contextWrite(context -> GrpcReactorContextBridge.write(context, snapshot));
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.grpc.advanced.reactor;
|
||||
|
||||
import dev.caskeleton.grpc.context.GrpcContextSnapshot;
|
||||
import dev.caskeleton.grpc.context.GrpcRequestContext;
|
||||
import java.util.function.BiFunction;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
|
||||
/**
|
||||
* Runs a reactive use case behind a gRPC service adapter.
|
||||
*
|
||||
* <p>The blocking scheduler is a required argument rather than a default. A reactive pipeline that
|
||||
* touches JPA or a blocking SDK and does not say where that happens runs it on the event loop, and
|
||||
* the symptom — every call on the connection slowing together — appears only under load and points
|
||||
* at the wrong component.
|
||||
*
|
||||
* @param <C> the application command type
|
||||
* @param <R> the application result type
|
||||
*/
|
||||
public final class ReactiveGrpcServerAdapter<C, R> {
|
||||
|
||||
private final BiFunction<C, GrpcRequestContext, Mono<R>> useCase;
|
||||
private final Scheduler blockingScheduler;
|
||||
|
||||
/** Binds an adapter to its use case and the scheduler blocking work runs on. */
|
||||
public ReactiveGrpcServerAdapter(
|
||||
BiFunction<C, GrpcRequestContext, Mono<R>> useCase, Scheduler blockingScheduler) {
|
||||
if (useCase == null || blockingScheduler == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"a reactive adapter needs a use case and the scheduler its blocking work runs on");
|
||||
}
|
||||
this.useCase = useCase;
|
||||
this.blockingScheduler = blockingScheduler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the use case with the context in both the Reactor context and the argument.
|
||||
*
|
||||
* <p>Both, because they are read by different code: application operators read the Reactor
|
||||
* context, and the use case signature reads the argument. Supplying only one leaves the other
|
||||
* empty at a boundary nobody expects.
|
||||
*/
|
||||
public Mono<R> invoke(C command, GrpcRequestContext context, GrpcContextSnapshot snapshot) {
|
||||
if (command == null || context == null || snapshot == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"an invocation needs a command, a request context and the snapshot to carry");
|
||||
}
|
||||
return Mono.defer(() -> useCase.apply(command, context))
|
||||
.subscribeOn(blockingScheduler)
|
||||
.contextWrite(reactorContext -> GrpcReactorContextBridge.write(reactorContext, snapshot));
|
||||
}
|
||||
|
||||
/** The scheduler blocking work runs on. */
|
||||
public Scheduler blockingScheduler() {
|
||||
return blockingScheduler;
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.grpc.advanced.servlet;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What a Servlet container can and cannot do compared with Netty.
|
||||
*
|
||||
* <p>The gaps are not incidental. A Servlet container owns the socket, so everything below the
|
||||
* request abstraction belongs to it: keepalive, connection age, and the flow-control window are the
|
||||
* container's settings, not gRPC's. Naming them is what stops a deployment from configuring a
|
||||
* keepalive that is silently ignored and concluding the client is at fault.
|
||||
*/
|
||||
public enum GrpcServletCapabilityMatrix {
|
||||
/** HTTP/2 request and response. Available. */
|
||||
HTTP2(true),
|
||||
/** TLS. Available, terminated by the container. */
|
||||
TLS(true),
|
||||
/** Inbound message and metadata limits. Available. */
|
||||
MESSAGE_LIMITS(true),
|
||||
/** The standard health service. Available. */
|
||||
HEALTH(true),
|
||||
/** Server reflection. Available. */
|
||||
REFLECTION(true),
|
||||
/** Graceful shutdown. Available through the container's lifecycle. */
|
||||
GRACEFUL_SHUTDOWN(true),
|
||||
/** gRPC-level keepalive tuning. The container owns the connection. */
|
||||
KEEPALIVE_TUNING(false),
|
||||
/** Maximum connection age. The container owns the connection. */
|
||||
MAX_CONNECTION_AGE(false),
|
||||
/** HTTP/2 flow-control window tuning. The container owns the transport. */
|
||||
FLOW_CONTROL_WINDOW_TUNING(false);
|
||||
|
||||
private final boolean available;
|
||||
|
||||
GrpcServletCapabilityMatrix(boolean available) {
|
||||
this.available = available;
|
||||
}
|
||||
|
||||
/** Whether the Servlet transport provides this. */
|
||||
public boolean available() {
|
||||
return available;
|
||||
}
|
||||
|
||||
/** Everything the Servlet transport cannot do. */
|
||||
public static Set<GrpcServletCapabilityMatrix> unavailable() {
|
||||
return java.util.Arrays.stream(values())
|
||||
.filter(capability -> !capability.available())
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package dev.caskeleton.grpc.advanced.servlet;
|
||||
|
||||
import dev.caskeleton.grpc.server.GrpcServerTransport;
|
||||
|
||||
/**
|
||||
* A deployment that serves gRPC from its Servlet container, on the web server's own port.
|
||||
*
|
||||
* <p>Attractive because it is one port, one TLS configuration and one lifecycle. The cost is that
|
||||
* the container owns the transport, so the Netty settings a Stable profile carries have no effect —
|
||||
* and this profile refuses to pretend otherwise.
|
||||
*
|
||||
* <p>Its evidence never counts as Netty certification. A suite that passes here has established
|
||||
* that the container serves gRPC, not that the platform's Netty profile is correct.
|
||||
*/
|
||||
public record GrpcServletCompatibilityProfile(
|
||||
String contextPath, boolean containerOwnsTls, boolean asyncSupported) {
|
||||
|
||||
/** Refuses a configuration the container cannot honour. */
|
||||
public GrpcServletCompatibilityProfile {
|
||||
if (contextPath == null || !contextPath.startsWith("/")) {
|
||||
throw new IllegalArgumentException(
|
||||
"a servlet profile needs a context path starting with '/'");
|
||||
}
|
||||
if (!asyncSupported) {
|
||||
throw new IllegalArgumentException(
|
||||
"gRPC over Servlet requires async support; without it every streaming call blocks a "
|
||||
+ "container thread for its lifetime");
|
||||
}
|
||||
if (!containerOwnsTls) {
|
||||
throw new IllegalArgumentException(
|
||||
"the container owns the socket, so it owns TLS; a profile that claims otherwise "
|
||||
+ "configures a setting nothing reads");
|
||||
}
|
||||
}
|
||||
|
||||
/** The transport this profile runs on. */
|
||||
public GrpcServerTransport transport() {
|
||||
return GrpcServerTransport.SERVLET;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a run under this profile certifies the platform's Netty transport.
|
||||
*
|
||||
* <p>Always false. The container's HTTP/2 is real, but it is the container's: its keepalive, its
|
||||
* connection age and its flow-control window. Stable certification is a statement about the
|
||||
* platform's own Netty profile, and a Servlet run establishes nothing about it.
|
||||
*/
|
||||
public boolean certifiesNettyTransport() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package dev.caskeleton.grpc.advanced.servlet;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Refuses a Servlet deployment that asks for something the container will not do.
|
||||
*
|
||||
* <p>Refuses rather than warns, because the setting would otherwise be accepted and ignored. A
|
||||
* keepalive configured on a Servlet deployment does nothing, the connections behave as the
|
||||
* container decides, and the investigation starts from the assumption that the setting is in force.
|
||||
*/
|
||||
public final class GrpcServletStartupValidator {
|
||||
|
||||
private GrpcServletStartupValidator() {}
|
||||
|
||||
/**
|
||||
* Every requested capability the Servlet transport cannot provide.
|
||||
*
|
||||
* @param requestedCapabilities the transport settings the deployment configured
|
||||
* @return an empty list when everything requested is available
|
||||
*/
|
||||
public static List<String> violations(
|
||||
GrpcServletCompatibilityProfile profile,
|
||||
Set<GrpcServletCapabilityMatrix> requestedCapabilities) {
|
||||
if (profile == null || requestedCapabilities == null) {
|
||||
throw new IllegalArgumentException("validation needs the profile and the requested set");
|
||||
}
|
||||
List<String> violations = new ArrayList<>();
|
||||
requestedCapabilities.stream()
|
||||
.filter(capability -> !capability.available())
|
||||
.sorted()
|
||||
.forEach(
|
||||
capability ->
|
||||
violations.add(
|
||||
capability
|
||||
+ " is not available on the Servlet transport; the container owns the "
|
||||
+ "connection, so this setting would be accepted and ignored"));
|
||||
return List.copyOf(violations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a Servlet run may stand in for Netty certification.
|
||||
*
|
||||
* <p>Always false.
|
||||
*/
|
||||
public static boolean substitutesForNettyCertification() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package dev.caskeleton.grpc.advanced.web;
|
||||
|
||||
import dev.caskeleton.grpc.core.GrpcMethodName;
|
||||
import dev.caskeleton.grpc.core.RpcType;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Checks that everything exposed to browsers can actually be served to them, on the same schema as
|
||||
* the native clients.
|
||||
*
|
||||
* <p>The same schema is the point. Two schemas — one for browsers, one for services — is how a
|
||||
* field ends up meaning something different depending on which client asked, and the divergence is
|
||||
* only visible to whoever reads both files.
|
||||
*/
|
||||
public final class GrpcWebCompatibilityGate {
|
||||
|
||||
private GrpcWebCompatibilityGate() {}
|
||||
|
||||
/**
|
||||
* Every method this profile could not serve.
|
||||
*
|
||||
* @param exposedMethods the methods a browser is meant to be able to call
|
||||
* @return an empty list when every exposed method is servable
|
||||
*/
|
||||
public static List<String> violations(Map<GrpcMethodName, RpcType> exposedMethods) {
|
||||
if (exposedMethods == null) {
|
||||
throw new IllegalArgumentException("an exposed method map is required");
|
||||
}
|
||||
List<String> violations = new ArrayList<>();
|
||||
exposedMethods.entrySet().stream()
|
||||
.sorted(java.util.Comparator.comparing(entry -> entry.getKey().canonical()))
|
||||
.forEach(
|
||||
entry -> {
|
||||
if (!GrpcWebRpcSupport.supported(entry.getValue())) {
|
||||
violations.add(
|
||||
"method '"
|
||||
+ entry.getKey().canonical()
|
||||
+ "' is "
|
||||
+ entry.getValue()
|
||||
+ ", which gRPC-Web cannot carry");
|
||||
}
|
||||
});
|
||||
return List.copyOf(violations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the browser and native suites must run against one schema.
|
||||
*
|
||||
* <p>Always true. Stated as a method so the property is asserted rather than described.
|
||||
*/
|
||||
public static boolean sharesOneSchemaWithNativeClients() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package dev.caskeleton.grpc.advanced.web;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* How a browser reaches the platform, and under what browser-specific rules.
|
||||
*
|
||||
* <p>Cookie credentials and bearer credentials are separated because their attack surfaces differ.
|
||||
* A cookie is attached by the browser to every request to the origin, which makes CSRF a real
|
||||
* concern and a CSRF defence mandatory; a bearer token the application attaches explicitly is not
|
||||
* sent automatically, and requiring a CSRF token there is ceremony. A profile that treats them the
|
||||
* same either leaves the first exposed or burdens the second.
|
||||
*/
|
||||
public record GrpcWebProfile(
|
||||
CredentialStyle credentialStyle,
|
||||
Set<String> allowedOrigins,
|
||||
boolean csrfProtection,
|
||||
boolean tlsTerminatedAtProxy) {
|
||||
|
||||
/** How the browser presents its credential. */
|
||||
public enum CredentialStyle {
|
||||
/** No credential. */
|
||||
NONE,
|
||||
/** A cookie the browser attaches automatically. Needs a CSRF defence. */
|
||||
COOKIE,
|
||||
/** A bearer token the application attaches explicitly. */
|
||||
BEARER
|
||||
}
|
||||
|
||||
/** Refuses a profile a browser could be tricked into using. */
|
||||
public GrpcWebProfile {
|
||||
if (credentialStyle == null) {
|
||||
throw new IllegalArgumentException("a gRPC-Web profile names its credential style");
|
||||
}
|
||||
if (allowedOrigins == null) {
|
||||
throw new IllegalArgumentException("a gRPC-Web profile states its origin allowlist");
|
||||
}
|
||||
allowedOrigins = Set.copyOf(allowedOrigins);
|
||||
if (allowedOrigins.contains("*")) {
|
||||
throw new IllegalArgumentException(
|
||||
"a wildcard origin lets any site call this API with the browser's ambient credentials");
|
||||
}
|
||||
if (credentialStyle != CredentialStyle.NONE && allowedOrigins.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a credentialed gRPC-Web profile needs an origin allowlist");
|
||||
}
|
||||
if (credentialStyle == CredentialStyle.COOKIE && !csrfProtection) {
|
||||
throw new IllegalArgumentException(
|
||||
"cookie credentials are attached by the browser to every request to this origin, so a "
|
||||
+ "CSRF defence is not optional");
|
||||
}
|
||||
if (!tlsTerminatedAtProxy && credentialStyle != CredentialStyle.NONE) {
|
||||
throw new IllegalArgumentException(
|
||||
"a credentialed browser call needs TLS terminated at the proxy");
|
||||
}
|
||||
}
|
||||
|
||||
/** A bearer-token profile for a single origin. */
|
||||
public static GrpcWebProfile bearer(String origin) {
|
||||
return new GrpcWebProfile(CredentialStyle.BEARER, Set.of(origin), false, true);
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package dev.caskeleton.grpc.advanced.web;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What a gRPC-Web proxy must be configured to do.
|
||||
*
|
||||
* <p>The exposed-trailer rule is the one that costs the most time when it is missing. A gRPC status
|
||||
* arrives as a trailer, a browser cannot read a trailer the proxy did not expose, and the symptom
|
||||
* is a call that appears to succeed at the network level and produces no status at all — which
|
||||
* looks like an application bug and is a proxy configuration.
|
||||
*/
|
||||
public final class GrpcWebProxyContract {
|
||||
|
||||
/** The trailers a browser client has to be able to read. */
|
||||
private static final Set<String> REQUIRED_EXPOSED_HEADERS = Set.of("grpc-status", "grpc-message");
|
||||
|
||||
private GrpcWebProxyContract() {}
|
||||
|
||||
/** The headers a proxy must expose. */
|
||||
public static Set<String> requiredExposedHeaders() {
|
||||
return REQUIRED_EXPOSED_HEADERS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every problem with a proxy configuration.
|
||||
*
|
||||
* @param exposedHeaders the CORS {@code expose_headers} the proxy is configured with
|
||||
* @param allowedOrigins the CORS origin allowlist the proxy is configured with
|
||||
* @return an empty list when the proxy would work for a browser client
|
||||
*/
|
||||
public static List<String> violations(
|
||||
GrpcWebProfile profile, Set<String> exposedHeaders, Set<String> allowedOrigins) {
|
||||
if (profile == null || exposedHeaders == null || allowedOrigins == null) {
|
||||
throw new IllegalArgumentException("a proxy check needs the profile and both CORS sets");
|
||||
}
|
||||
List<String> violations = new ArrayList<>();
|
||||
|
||||
REQUIRED_EXPOSED_HEADERS.stream()
|
||||
.sorted()
|
||||
.filter(header -> !exposedHeaders.contains(header))
|
||||
.forEach(
|
||||
header ->
|
||||
violations.add(
|
||||
"the proxy does not expose '"
|
||||
+ header
|
||||
+ "'; a browser cannot read a trailer it was not given, so the call "
|
||||
+ "produces no status at all"));
|
||||
|
||||
if (allowedOrigins.contains("*")) {
|
||||
violations.add("the proxy allows any origin, which defeats the profile's allowlist");
|
||||
}
|
||||
profile.allowedOrigins().stream()
|
||||
.sorted()
|
||||
.filter(origin -> !allowedOrigins.contains(origin))
|
||||
.forEach(
|
||||
origin ->
|
||||
violations.add(
|
||||
"origin '" + origin + "' is in the profile but not in the proxy's allowlist"));
|
||||
return List.copyOf(violations);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.grpc.advanced.web;
|
||||
|
||||
import dev.caskeleton.grpc.core.RpcType;
|
||||
|
||||
/**
|
||||
* Which RPC shapes gRPC-Web can actually carry.
|
||||
*
|
||||
* <p>Two, and the limit is the protocol's rather than this platform's: gRPC-Web has no way for a
|
||||
* browser to send a stream of messages, so client and bidirectional streaming are not slow or
|
||||
* partial there — they do not exist. Declaring support for them produces a schema a browser client
|
||||
* cannot use and a discovery that happens in the browser.
|
||||
*/
|
||||
public final class GrpcWebRpcSupport {
|
||||
|
||||
private GrpcWebRpcSupport() {}
|
||||
|
||||
/** Whether {@code rpcType} can travel over gRPC-Web. */
|
||||
public static boolean supported(RpcType rpcType) {
|
||||
if (rpcType == null) {
|
||||
throw new IllegalArgumentException("an RPC type is required");
|
||||
}
|
||||
return rpcType == RpcType.UNARY || rpcType == RpcType.SERVER_STREAMING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails when a method shape cannot be served over gRPC-Web.
|
||||
*
|
||||
* @throws IllegalArgumentException naming what the browser cannot do
|
||||
*/
|
||||
public static void require(RpcType rpcType) {
|
||||
if (!supported(rpcType)) {
|
||||
throw new IllegalArgumentException(
|
||||
rpcType
|
||||
+ " cannot travel over gRPC-Web: a browser has no way to send a stream of messages, "
|
||||
+ "so declaring support for it produces a schema no browser client can use");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# The gRPC-Web proxy contract, as a reference Envoy configuration.
|
||||
#
|
||||
# Shipped as a resource rather than as documentation prose because GrpcWebProxyContract asserts
|
||||
# against it: the CORS allowlist, the exposed trailer headers and the TLS termination are the three
|
||||
# things a browser client silently fails without, and a contract nobody checks is a contract that
|
||||
# drifts from whatever is actually deployed.
|
||||
#
|
||||
# The exposed headers matter most and are the least obvious. gRPC statuses arrive as trailers, and a
|
||||
# browser cannot read a trailer the proxy did not expose; the symptom is a call that appears to hang
|
||||
# and then fails with no status at all.
|
||||
static_resources:
|
||||
listeners:
|
||||
- name: grpc_web_listener
|
||||
address:
|
||||
socket_address: { address: 0.0.0.0, port_value: 8443 }
|
||||
filter_chains:
|
||||
- filters:
|
||||
- name: envoy.filters.network.http_connection_manager
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
|
||||
stat_prefix: grpc_web
|
||||
codec_type: AUTO
|
||||
route_config:
|
||||
name: grpc_web_route
|
||||
virtual_hosts:
|
||||
- name: grpc_web_host
|
||||
domains: ["*"]
|
||||
routes:
|
||||
- match: { prefix: "/" }
|
||||
route:
|
||||
cluster: grpc_backend
|
||||
timeout: 30s
|
||||
cors:
|
||||
allow_origin_string_match:
|
||||
- exact: "https://app.example.com"
|
||||
allow_methods: "POST,OPTIONS"
|
||||
allow_headers: "content-type,x-grpc-web,x-correlation-id,authorization"
|
||||
expose_headers: "grpc-status,grpc-message,error-code,error-category"
|
||||
max_age: "1728000"
|
||||
http_filters:
|
||||
- name: envoy.filters.http.grpc_web
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
|
||||
- name: envoy.filters.http.cors
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
|
||||
- name: envoy.filters.http.router
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
|
||||
transport_socket:
|
||||
name: envoy.transport_sockets.tls
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
|
||||
clusters:
|
||||
- name: grpc_backend
|
||||
connect_timeout: 1s
|
||||
type: STRICT_DNS
|
||||
lb_policy: ROUND_ROBIN
|
||||
typed_extension_protocol_options:
|
||||
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
|
||||
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
|
||||
explicit_http_config:
|
||||
http2_protocol_options: {}
|
||||
load_assignment:
|
||||
cluster_name: grpc_backend
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address: { address: documents, port_value: 9090 }
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package dev.caskeleton.grpc.advanced.compat;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.integration.GrpcIntegrationBridgePolicy;
|
||||
import dev.caskeleton.grpc.advanced.integration.GrpcIntegrationInboundGateway;
|
||||
import dev.caskeleton.grpc.advanced.integration.GrpcIntegrationOutboundGateway;
|
||||
import dev.caskeleton.grpc.context.GrpcClientIdentity;
|
||||
import dev.caskeleton.grpc.context.GrpcMetadataBudget;
|
||||
import dev.caskeleton.grpc.context.GrpcMetadataKey;
|
||||
import dev.caskeleton.grpc.context.GrpcRequestContext;
|
||||
import dev.caskeleton.grpc.core.GrpcMethodName;
|
||||
import dev.caskeleton.grpc.core.RpcType;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationToken;
|
||||
import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget;
|
||||
import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.support.MessageBuilder;
|
||||
|
||||
class GrpcIntegrationBridgePolicyTest {
|
||||
|
||||
private static final GrpcMetadataKey CORRELATION = GrpcMetadataKey.ascii("x-correlation-id");
|
||||
private static final GrpcMethodName GET =
|
||||
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
|
||||
|
||||
private static GrpcRequestContext requestContext() {
|
||||
return GrpcRequestContext.create(
|
||||
GET,
|
||||
RpcType.UNARY,
|
||||
GrpcClientIdentity.fromVerifiedAuthentication("actor-1", "tenant-1", "jwt-issuer"),
|
||||
GrpcDeadlineBudget.forEntryPoint(
|
||||
Duration.ofSeconds(1), GrpcDeadlineProfile.of(Duration.ofSeconds(2))),
|
||||
new GrpcCancellationToken(),
|
||||
Map.of(CORRELATION, "corr-1"),
|
||||
Set.of(CORRELATION),
|
||||
GrpcMetadataBudget.standard(),
|
||||
null);
|
||||
}
|
||||
|
||||
private static GrpcIntegrationBridgePolicy policy() {
|
||||
return new GrpcIntegrationBridgePolicy(Set.of(CORRELATION), Set.of(String.class.getName()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the bridge copies only allowlisted headers onto metadata")
|
||||
void theBridgeCopiesOnlyAllowlistedHeaders() {
|
||||
GrpcIntegrationOutboundGateway<String> outbound =
|
||||
new GrpcIntegrationOutboundGateway<>(policy());
|
||||
Message<String> message =
|
||||
MessageBuilder.withPayload("payload")
|
||||
.setHeader(CORRELATION.name(), "corr-1")
|
||||
.setHeader("errorChannel", "internal-errors")
|
||||
.setHeader("replyChannel", "internal-replies")
|
||||
.build();
|
||||
|
||||
assertThat(outbound.metadataFrom(message)).containsOnlyKeys(CORRELATION);
|
||||
assertThat(outbound.payloadFrom(message)).isEqualTo("payload");
|
||||
assertThat(outbound.policy()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unregistered payload type is refused rather than converted by reflection")
|
||||
void anUnregisteredPayloadIsRefused() {
|
||||
GrpcIntegrationBridgePolicy otherType =
|
||||
new GrpcIntegrationBridgePolicy(Set.of(CORRELATION), Set.of("some.other.Type"));
|
||||
GrpcIntegrationInboundGateway<String> inbound = new GrpcIntegrationInboundGateway<>(otherType);
|
||||
|
||||
assertThatThrownBy(() -> inbound.toMessage("payload", requestContext()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("converting by reflection");
|
||||
Message<String> message = MessageBuilder.withPayload("payload").build();
|
||||
GrpcIntegrationOutboundGateway<String> outbound =
|
||||
new GrpcIntegrationOutboundGateway<>(otherType);
|
||||
assertThatThrownBy(() -> outbound.payloadFrom(message))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a bridge with no registered converter is refused at construction")
|
||||
void aBridgeWithNoConverterIsRefused() {
|
||||
assertThatThrownBy(() -> new GrpcIntegrationBridgePolicy(Set.of(), Set.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("leaving it to reflection");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the request context travels as one immutable header, not as scattered fields")
|
||||
void theContextTravelsAsOneHeader() {
|
||||
GrpcIntegrationInboundGateway<String> inbound = new GrpcIntegrationInboundGateway<>(policy());
|
||||
|
||||
Message<String> message = inbound.toMessage("payload", requestContext());
|
||||
|
||||
assertThat(message.getHeaders())
|
||||
.containsKey(GrpcIntegrationInboundGateway.CONTEXT_HEADER)
|
||||
.containsKey(CORRELATION.name())
|
||||
.doesNotContainKey("tenantId")
|
||||
.doesNotContainKey("actorId");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the bridge adds no broker semantics and replaces no generated API")
|
||||
void theBridgeClaimsNothingItCannotDo() {
|
||||
assertThat(policy().providesBrokerSemantics()).isFalse();
|
||||
assertThat(policy().replacesGeneratedApis()).isFalse();
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package dev.caskeleton.grpc.advanced.compat;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.kotlin.GrpcCoroutineContextBridge;
|
||||
import dev.caskeleton.grpc.advanced.kotlin.GrpcKotlinCompatibilityGate;
|
||||
import dev.caskeleton.grpc.advanced.kotlin.GrpcKotlinProfile;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationCoordinator;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationReason;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationToken;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcKotlinCompatibilityGateTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
|
||||
|
||||
@Test
|
||||
@DisplayName("the Kotlin adapter fails closed here, and says which lane is missing")
|
||||
void theGateFailsClosed() {
|
||||
GrpcKotlinProfile complete = new GrpcKotlinProfile(true, true, true, true, "2.1.0");
|
||||
|
||||
assertThat(GrpcKotlinCompatibilityGate.blockers(complete, false))
|
||||
.singleElement()
|
||||
.satisfies(blocker -> assertThat(blocker).contains("no Kotlin toolchain"));
|
||||
assertThat(GrpcKotlinCompatibilityGate.blockers(complete, true)).isEmpty();
|
||||
assertThat(GrpcKotlinCompatibilityGate.supportableHere()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("each Kotlin contract requirement is checked on its own")
|
||||
void eachRequirementIsCheckedSeparately() {
|
||||
assertThat(GrpcKotlinCompatibilityGate.blockers(GrpcKotlinProfile.unverified("2.1.0"), true))
|
||||
.hasSize(4)
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("one schema source"))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("cancelled scope"))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("buffer without a limit"))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("second model of the same facts"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a profile that claims support without naming a toolchain is refused")
|
||||
void aProfileNamesItsToolchain() {
|
||||
assertThatThrownBy(() -> GrpcKotlinProfile.unverified(" "))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("not a version");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("coroutine cancellation reaches the platform, and the platform reaches the scope")
|
||||
void cancellationCrossesBothWays() {
|
||||
GrpcCancellationCoordinator inbound =
|
||||
new GrpcCancellationCoordinator(new GrpcCancellationToken());
|
||||
GrpcCoroutineContextBridge.onCoroutineCancelled(inbound, () -> NOW).run();
|
||||
assertThat(inbound.cancelled()).isTrue();
|
||||
|
||||
GrpcCancellationCoordinator outbound =
|
||||
new GrpcCancellationCoordinator(new GrpcCancellationToken());
|
||||
AtomicBoolean scopeCancelled = new AtomicBoolean();
|
||||
GrpcCoroutineContextBridge.cancelCoroutineScope(
|
||||
outbound, () -> scopeCancelled.set(true), "document-flow");
|
||||
outbound.cancel(GrpcCancellationReason.SERVER_DRAIN, NOW);
|
||||
|
||||
assertThat(scopeCancelled).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("binding a coroutine scope without a name is refused")
|
||||
void bindingNeedsAnOperationName() {
|
||||
GrpcCancellationCoordinator coordinator =
|
||||
new GrpcCancellationCoordinator(new GrpcCancellationToken());
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> GrpcCoroutineContextBridge.cancelCoroutineScope(coordinator, () -> {}, " "))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package dev.caskeleton.grpc.advanced.compat;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.reactor.GrpcReactorCancellationBridge;
|
||||
import dev.caskeleton.grpc.advanced.reactor.GrpcReactorContextBridge;
|
||||
import dev.caskeleton.grpc.context.GrpcClientIdentity;
|
||||
import dev.caskeleton.grpc.context.GrpcContextSnapshot;
|
||||
import dev.caskeleton.grpc.context.GrpcMetadataBudget;
|
||||
import dev.caskeleton.grpc.context.GrpcRequestContext;
|
||||
import dev.caskeleton.grpc.core.GrpcMethodName;
|
||||
import dev.caskeleton.grpc.core.RpcType;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationCoordinator;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationReason;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationToken;
|
||||
import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget;
|
||||
import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.util.context.Context;
|
||||
|
||||
class GrpcReactorContextBridgeTest {
|
||||
|
||||
private static final GrpcMethodName GET =
|
||||
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
|
||||
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
|
||||
|
||||
private static GrpcContextSnapshot snapshot() {
|
||||
GrpcRequestContext request =
|
||||
GrpcRequestContext.create(
|
||||
GET,
|
||||
RpcType.UNARY,
|
||||
GrpcClientIdentity.fromVerifiedAuthentication("actor-1", "tenant-1", "jwt-issuer"),
|
||||
GrpcDeadlineBudget.forEntryPoint(
|
||||
Duration.ofSeconds(1), GrpcDeadlineProfile.of(Duration.ofSeconds(2))),
|
||||
new GrpcCancellationToken(),
|
||||
Map.of(),
|
||||
Set.of(),
|
||||
GrpcMetadataBudget.standard(),
|
||||
null);
|
||||
return GrpcContextSnapshot.of(request, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the call context crosses into and out of a Reactor context")
|
||||
void theContextCrossesIntoReactor() {
|
||||
GrpcContextSnapshot snapshot = snapshot();
|
||||
|
||||
Context context = GrpcReactorContextBridge.write(Context.empty(), snapshot);
|
||||
|
||||
assertThat(GrpcReactorContextBridge.read(context)).contains(snapshot);
|
||||
assertThat(GrpcReactorContextBridge.require(context).identity().actorId()).isEqualTo("actor-1");
|
||||
assertThat(GrpcReactorContextBridge.contextKey()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("reactive work with no call context fails closed")
|
||||
void contextlessReactiveWorkFailsClosed() {
|
||||
assertThat(GrpcReactorContextBridge.read(Context.empty())).isEmpty();
|
||||
assertThatThrownBy(() -> GrpcReactorContextBridge.require(Context.empty()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("attributed to nobody");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a Reactor cancellation reaches the platform coordinator")
|
||||
void reactorCancellationReachesThePlatform() {
|
||||
GrpcCancellationCoordinator coordinator =
|
||||
new GrpcCancellationCoordinator(new GrpcCancellationToken());
|
||||
|
||||
GrpcReactorCancellationBridge.onReactorCancel(coordinator, () -> NOW).run();
|
||||
|
||||
assertThat(coordinator.cancelled()).isTrue();
|
||||
assertThat(coordinator.reason()).contains(GrpcCancellationReason.CLIENT_CANCELLED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a platform cancellation disposes the reactive subscription")
|
||||
void platformCancellationDisposesTheSubscription() {
|
||||
GrpcCancellationCoordinator coordinator =
|
||||
new GrpcCancellationCoordinator(new GrpcCancellationToken());
|
||||
AtomicBoolean disposed = new AtomicBoolean();
|
||||
|
||||
GrpcReactorCancellationBridge.bindPlatformCancellation(
|
||||
coordinator, () -> disposed.set(true), "document-query");
|
||||
coordinator.cancel(GrpcCancellationReason.DEADLINE_EXCEEDED, NOW);
|
||||
|
||||
assertThat(disposed).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("binding a subscription without a name is refused")
|
||||
void bindingNeedsAnOperationName() {
|
||||
GrpcCancellationCoordinator coordinator =
|
||||
new GrpcCancellationCoordinator(new GrpcCancellationToken());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
GrpcReactorCancellationBridge.bindPlatformCancellation(coordinator, () -> {}, " "))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package dev.caskeleton.grpc.advanced.compat;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.servlet.GrpcServletCapabilityMatrix;
|
||||
import dev.caskeleton.grpc.advanced.servlet.GrpcServletCompatibilityProfile;
|
||||
import dev.caskeleton.grpc.advanced.servlet.GrpcServletStartupValidator;
|
||||
import dev.caskeleton.grpc.server.GrpcServerTransport;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcServletStartupValidatorTest {
|
||||
|
||||
private static final GrpcServletCompatibilityProfile PROFILE =
|
||||
new GrpcServletCompatibilityProfile("/grpc", true, true);
|
||||
|
||||
@Test
|
||||
@DisplayName("the Servlet transport names what it cannot do")
|
||||
void theMatrixNamesItsGaps() {
|
||||
assertThat(GrpcServletCapabilityMatrix.unavailable())
|
||||
.containsExactlyInAnyOrder(
|
||||
GrpcServletCapabilityMatrix.KEEPALIVE_TUNING,
|
||||
GrpcServletCapabilityMatrix.MAX_CONNECTION_AGE,
|
||||
GrpcServletCapabilityMatrix.FLOW_CONTROL_WINDOW_TUNING);
|
||||
assertThat(GrpcServletCapabilityMatrix.HTTP2.available()).isTrue();
|
||||
assertThat(GrpcServletCapabilityMatrix.GRACEFUL_SHUTDOWN.available()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a deployment asking for a Netty-only setting is refused, not warned")
|
||||
void aNettyOnlySettingIsRefused() {
|
||||
assertThat(
|
||||
GrpcServletStartupValidator.violations(
|
||||
PROFILE, Set.of(GrpcServletCapabilityMatrix.KEEPALIVE_TUNING)))
|
||||
.singleElement()
|
||||
.satisfies(violation -> assertThat(violation).contains("accepted and ignored"));
|
||||
assertThat(
|
||||
GrpcServletStartupValidator.violations(
|
||||
PROFILE,
|
||||
Set.of(
|
||||
GrpcServletCapabilityMatrix.KEEPALIVE_TUNING,
|
||||
GrpcServletCapabilityMatrix.MAX_CONNECTION_AGE)))
|
||||
.hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an available capability passes")
|
||||
void anAvailableCapabilityPasses() {
|
||||
assertThat(
|
||||
GrpcServletStartupValidator.violations(
|
||||
PROFILE,
|
||||
Set.of(GrpcServletCapabilityMatrix.HTTP2, GrpcServletCapabilityMatrix.TLS)))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a Servlet run never substitutes for Netty certification")
|
||||
void servletIsNotNettyCertification() {
|
||||
assertThat(GrpcServletStartupValidator.substitutesForNettyCertification()).isFalse();
|
||||
assertThat(PROFILE.certifiesNettyTransport()).isFalse();
|
||||
assertThat(PROFILE.transport()).isEqualTo(GrpcServerTransport.SERVLET);
|
||||
assertThat(GrpcServerTransport.SERVLET.certifiesNetworkBehaviour()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a profile without async support or container-owned TLS is refused")
|
||||
void anIncoherentProfileIsRefused() {
|
||||
assertThatThrownBy(() -> new GrpcServletCompatibilityProfile("/grpc", true, false))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("blocks a container thread");
|
||||
assertThatThrownBy(() -> new GrpcServletCompatibilityProfile("/grpc", false, true))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("a setting nothing reads");
|
||||
assertThatThrownBy(() -> new GrpcServletCompatibilityProfile("grpc", true, true))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package dev.caskeleton.grpc.advanced.compat;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.web.GrpcWebCompatibilityGate;
|
||||
import dev.caskeleton.grpc.advanced.web.GrpcWebProfile;
|
||||
import dev.caskeleton.grpc.advanced.web.GrpcWebProxyContract;
|
||||
import dev.caskeleton.grpc.advanced.web.GrpcWebRpcSupport;
|
||||
import dev.caskeleton.grpc.core.GrpcMethodName;
|
||||
import dev.caskeleton.grpc.core.RpcType;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcWebCompatibilityGateTest {
|
||||
|
||||
private static final GrpcMethodName GET =
|
||||
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
|
||||
private static final GrpcMethodName UPLOAD =
|
||||
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/UploadDocument");
|
||||
|
||||
@Test
|
||||
@DisplayName("gRPC-Web carries unary and server streaming, and nothing else")
|
||||
void grpcWebCarriesTwoShapes() {
|
||||
assertThat(GrpcWebRpcSupport.supported(RpcType.UNARY)).isTrue();
|
||||
assertThat(GrpcWebRpcSupport.supported(RpcType.SERVER_STREAMING)).isTrue();
|
||||
assertThat(GrpcWebRpcSupport.supported(RpcType.CLIENT_STREAMING)).isFalse();
|
||||
assertThat(GrpcWebRpcSupport.supported(RpcType.BIDI_STREAMING)).isFalse();
|
||||
assertThatThrownBy(() -> GrpcWebRpcSupport.require(RpcType.BIDI_STREAMING))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("no way to send a stream of messages");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a client-streaming method exposed to browsers is reported")
|
||||
void anUnservableMethodIsReported() {
|
||||
assertThat(
|
||||
GrpcWebCompatibilityGate.violations(
|
||||
Map.of(GET, RpcType.UNARY, UPLOAD, RpcType.CLIENT_STREAMING)))
|
||||
.singleElement()
|
||||
.satisfies(violation -> assertThat(violation).contains("UploadDocument"));
|
||||
assertThat(GrpcWebCompatibilityGate.violations(Map.of(GET, RpcType.UNARY))).isEmpty();
|
||||
assertThat(GrpcWebCompatibilityGate.sharesOneSchemaWithNativeClients()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a wildcard origin is refused, and cookie credentials require CSRF protection")
|
||||
void browserCredentialRulesDifferByStyle() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcWebProfile(GrpcWebProfile.CredentialStyle.BEARER, Set.of("*"), false, true))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("ambient credentials");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcWebProfile(
|
||||
GrpcWebProfile.CredentialStyle.COOKIE,
|
||||
Set.of("https://app.example.com"),
|
||||
false,
|
||||
true))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("CSRF defence is not optional");
|
||||
assertThat(GrpcWebProfile.bearer("https://app.example.com").csrfProtection()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a credentialed profile needs an origin allowlist and TLS at the proxy")
|
||||
void aCredentialedProfileNeedsBoth() {
|
||||
assertThatThrownBy(
|
||||
() -> new GrpcWebProfile(GrpcWebProfile.CredentialStyle.BEARER, Set.of(), false, true))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("origin allowlist");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcWebProfile(
|
||||
GrpcWebProfile.CredentialStyle.BEARER,
|
||||
Set.of("https://app.example.com"),
|
||||
false,
|
||||
false))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("TLS terminated at the proxy");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a proxy that hides the status trailers is reported")
|
||||
void aProxyMustExposeTheStatusTrailers() {
|
||||
GrpcWebProfile profile = GrpcWebProfile.bearer("https://app.example.com");
|
||||
|
||||
assertThat(
|
||||
GrpcWebProxyContract.violations(
|
||||
profile, Set.of("grpc-status"), Set.of("https://app.example.com")))
|
||||
.anySatisfy(violation -> assertThat(violation).contains("grpc-message"));
|
||||
assertThat(
|
||||
GrpcWebProxyContract.violations(
|
||||
profile,
|
||||
GrpcWebProxyContract.requiredExposedHeaders(),
|
||||
Set.of("https://app.example.com")))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a proxy allowing any origin, or missing the profile's, is reported")
|
||||
void proxyOriginMismatchesAreReported() {
|
||||
GrpcWebProfile profile = GrpcWebProfile.bearer("https://app.example.com");
|
||||
|
||||
assertThat(
|
||||
GrpcWebProxyContract.violations(
|
||||
profile, GrpcWebProxyContract.requiredExposedHeaders(), Set.of("*")))
|
||||
.anySatisfy(violation -> assertThat(violation).contains("defeats the profile's allowlist"));
|
||||
assertThat(
|
||||
GrpcWebProxyContract.violations(
|
||||
profile, GrpcWebProxyContract.requiredExposedHeaders(), Set.of()))
|
||||
.anySatisfy(violation -> assertThat(violation).contains("not in the proxy's allowlist"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the reference Envoy configuration exposes the trailers and pins the origin")
|
||||
void theReferenceProxyConfigurationIsCorrect() {
|
||||
String envoy = resource("envoy/envoy.yaml");
|
||||
|
||||
assertThat(envoy)
|
||||
.contains("expose_headers: \"grpc-status,grpc-message")
|
||||
.contains("exact: \"https://app.example.com\"")
|
||||
.contains("envoy.filters.http.grpc_web");
|
||||
}
|
||||
|
||||
private static String resource(String path) {
|
||||
try (InputStream stream =
|
||||
GrpcWebCompatibilityGateTest.class.getClassLoader().getResourceAsStream(path)) {
|
||||
if (stream == null) {
|
||||
throw new IllegalStateException("missing resource " + path);
|
||||
}
|
||||
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
// Channelz/CSDS diagnostics for administrators, with the redactor that keeps socket authority,
|
||||
// credentials, certificate material, metadata and payload out of a snapshot, plus the advanced
|
||||
// infrastructure testkit contract (gRPC-Web proxy, Servlet container, xDS control-plane failure).
|
||||
dependencies {
|
||||
api project(':grpc:grpc-core-api')
|
||||
api project(':grpc:grpc-client')
|
||||
api project(':grpc-advanced:grpc-advanced-bootstrap')
|
||||
}
|
||||
@@ -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=
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package dev.caskeleton.grpc.advanced.diagnostics;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What an advanced capability has to be exercised against before it counts as verified.
|
||||
*
|
||||
* <p>Real infrastructure, named per capability. gRPC-Web without a proxy tests a code path no
|
||||
* browser will take; a Servlet profile without a container tests the profile object; xDS without a
|
||||
* control plane cannot exercise the case that matters, which is the control plane going away. In
|
||||
* all three, a suite that runs without the infrastructure passes and establishes nothing, which is
|
||||
* worse than not having one.
|
||||
*/
|
||||
public final class GrpcAdvancedInfrastructureTestkit {
|
||||
|
||||
private GrpcAdvancedInfrastructureTestkit() {}
|
||||
|
||||
/** A piece of infrastructure a capability's suite needs. */
|
||||
public enum Infrastructure {
|
||||
/** An Envoy or equivalent gRPC-Web proxy. */
|
||||
GRPC_WEB_PROXY,
|
||||
/** A Servlet container serving HTTP/2. */
|
||||
SERVLET_CONTAINER,
|
||||
/** An xDS control plane that can be stopped. */
|
||||
XDS_CONTROL_PLANE,
|
||||
/** A Kotlin toolchain. */
|
||||
KOTLIN_TOOLCHAIN
|
||||
}
|
||||
|
||||
/** What {@code capability} needs before its evidence means anything. */
|
||||
public static Set<Infrastructure> requiredFor(GrpcAdvancedCapability capability) {
|
||||
if (capability == null) {
|
||||
throw new IllegalArgumentException("a capability is required");
|
||||
}
|
||||
return switch (capability) {
|
||||
case GRPC_WEB -> Set.of(Infrastructure.GRPC_WEB_PROXY);
|
||||
case SERVLET_COMPAT -> Set.of(Infrastructure.SERVLET_CONTAINER);
|
||||
case XDS -> Set.of(Infrastructure.XDS_CONTROL_PLANE);
|
||||
case KOTLIN -> Set.of(Infrastructure.KOTLIN_TOOLCHAIN);
|
||||
case EDITION_2024,
|
||||
EDITION_2026,
|
||||
CLIENT_STREAMING,
|
||||
BIDI_STREAMING,
|
||||
MANUAL_FLOW_CONTROL,
|
||||
HEDGING,
|
||||
CUSTOM_RESOLVER,
|
||||
CUSTOM_LOAD_BALANCER,
|
||||
INTEGRATION_BRIDGE,
|
||||
REACTOR,
|
||||
CHANNEL_DIAGNOSTICS ->
|
||||
Set.of();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every piece of infrastructure a capability needs and does not have.
|
||||
*
|
||||
* @return an empty list when the suite can produce evidence that means something
|
||||
*/
|
||||
public static List<String> missingInfrastructure(
|
||||
GrpcAdvancedCapability capability, Set<Infrastructure> available) {
|
||||
if (available == null) {
|
||||
throw new IllegalArgumentException("the available infrastructure set is required");
|
||||
}
|
||||
Set<Infrastructure> required = requiredFor(capability);
|
||||
if (required.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Set<Infrastructure> missing = EnumSet.copyOf(required);
|
||||
missing.removeAll(available);
|
||||
List<String> gaps = new ArrayList<>();
|
||||
missing.stream()
|
||||
.sorted()
|
||||
.forEach(
|
||||
infrastructure ->
|
||||
gaps.add(
|
||||
capability.flagName()
|
||||
+ " needs "
|
||||
+ infrastructure
|
||||
+ "; a suite that runs without it passes and establishes nothing"));
|
||||
return List.copyOf(gaps);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package dev.caskeleton.grpc.advanced.diagnostics;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedFeatureFlags;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedModuleGuard;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Who may read diagnostics, and which services are registered at all.
|
||||
*
|
||||
* <p>CSDS is registered only when xDS is enabled. A CSDS service on a deployment that does not use
|
||||
* xDS answers every query with nothing, which is harmless, and advertises a control-plane surface
|
||||
* that does not exist, which is not: it is one more endpoint to scan and one more thing whose
|
||||
* absence of authorization nobody notices.
|
||||
*/
|
||||
public record GrpcChannelDiagnosticsPolicy(Set<String> adminNetworks, Set<String> adminRoles) {
|
||||
|
||||
/** Refuses a policy with only one gate. */
|
||||
public GrpcChannelDiagnosticsPolicy {
|
||||
if (adminNetworks == null || adminRoles == null) {
|
||||
throw new IllegalArgumentException("a diagnostics policy states both gates");
|
||||
}
|
||||
adminNetworks = Set.copyOf(adminNetworks);
|
||||
adminRoles = Set.copyOf(adminRoles);
|
||||
if (adminNetworks.isEmpty() || adminRoles.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"diagnostics need both a network and a role gate; Channelz holds every socket's peer and "
|
||||
+ "security detail, so either gate alone is the whole surface");
|
||||
}
|
||||
}
|
||||
|
||||
/** This repository's default. */
|
||||
public static GrpcChannelDiagnosticsPolicy standard() {
|
||||
return new GrpcChannelDiagnosticsPolicy(Set.of("admin"), Set.of("ROLE_PLATFORM_ADMIN"));
|
||||
}
|
||||
|
||||
/** Whether this caller may read a snapshot. */
|
||||
public boolean mayRead(String callerNetwork, Set<String> callerRoles) {
|
||||
return callerNetwork != null
|
||||
&& adminNetworks.contains(callerNetwork)
|
||||
&& callerRoles != null
|
||||
&& callerRoles.stream().anyMatch(adminRoles::contains);
|
||||
}
|
||||
|
||||
/** Whether the Channelz service should be registered. */
|
||||
public static boolean registerChannelz(GrpcAdvancedFeatureFlags flags) {
|
||||
return GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.CHANNEL_DIAGNOSTICS);
|
||||
}
|
||||
|
||||
/** Whether the CSDS service should be registered. Only when xDS is actually in use. */
|
||||
public static boolean registerCsds(GrpcAdvancedFeatureFlags flags) {
|
||||
return registerChannelz(flags)
|
||||
&& GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.XDS);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package dev.caskeleton.grpc.advanced.diagnostics;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* What Channelz and CSDS report, after redaction.
|
||||
*
|
||||
* <p>Counters and states rather than call contents. The questions a diagnostics endpoint exists to
|
||||
* answer — which subchannels are connected, how many calls are in flight, which xDS resources the
|
||||
* control plane last sent — are all answerable from aggregates, and aggregates cannot leak a
|
||||
* caller's data.
|
||||
*/
|
||||
public record GrpcChannelDiagnosticsSnapshot(
|
||||
String channelProfile,
|
||||
String connectivityState,
|
||||
int subchannelCount,
|
||||
long callsStarted,
|
||||
long callsSucceeded,
|
||||
long callsFailed,
|
||||
List<String> maskedSocketAddresses,
|
||||
Map<String, String> xdsResourceVersions,
|
||||
Instant capturedAt) {
|
||||
|
||||
/** Refuses a snapshot carrying something it should not. */
|
||||
public GrpcChannelDiagnosticsSnapshot {
|
||||
if (channelProfile == null || channelProfile.isBlank()) {
|
||||
throw new IllegalArgumentException("a diagnostics snapshot names its channel profile");
|
||||
}
|
||||
if (connectivityState == null || connectivityState.isBlank()) {
|
||||
throw new IllegalArgumentException("a diagnostics snapshot carries a connectivity state");
|
||||
}
|
||||
if (subchannelCount < 0 || callsStarted < 0 || callsSucceeded < 0 || callsFailed < 0) {
|
||||
throw new IllegalArgumentException("diagnostics counters must not be negative");
|
||||
}
|
||||
if (maskedSocketAddresses == null || xdsResourceVersions == null || capturedAt == null) {
|
||||
throw new IllegalArgumentException("every snapshot section must be present");
|
||||
}
|
||||
maskedSocketAddresses = List.copyOf(maskedSocketAddresses);
|
||||
xdsResourceVersions = Map.copyOf(xdsResourceVersions);
|
||||
|
||||
maskedSocketAddresses.stream()
|
||||
.filter(address -> !address.equals(GrpcDiagnosticsRedactor.maskAddress(address)))
|
||||
.findFirst()
|
||||
.ifPresent(
|
||||
unmasked -> {
|
||||
throw new IllegalArgumentException(
|
||||
"socket address '"
|
||||
+ unmasked
|
||||
+ "' is not masked; a diagnostics endpoint that publishes peer addresses "
|
||||
+ "publishes every tenant's connection");
|
||||
});
|
||||
xdsResourceVersions.keySet().stream()
|
||||
.filter(GrpcDiagnosticsRedactor::forbiddenField)
|
||||
.findFirst()
|
||||
.ifPresent(
|
||||
forbidden -> {
|
||||
throw new IllegalArgumentException(
|
||||
"field '" + forbidden + "' may not appear in a diagnostics snapshot");
|
||||
});
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.caskeleton.grpc.advanced.diagnostics;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Removes from a diagnostics snapshot everything that is not a diagnostic.
|
||||
*
|
||||
* <p>Channelz is unusually dangerous to expose because it is genuinely useful: it holds every
|
||||
* socket's local and remote address, the security details of each connection, and per-call state.
|
||||
* An administrator debugging a routing problem needs the shape of that; nobody needs the peer
|
||||
* addresses of every tenant's connection, and once the endpoint exists the whole of it is one
|
||||
* authorization mistake away from being readable.
|
||||
*
|
||||
* <p>Addresses are masked rather than dropped. An operator has to be able to tell two subchannels
|
||||
* apart, and a stable mask does that without publishing where they point.
|
||||
*/
|
||||
public final class GrpcDiagnosticsRedactor {
|
||||
|
||||
private static final Pattern SENSITIVE_FIELD =
|
||||
Pattern.compile(
|
||||
"(?i).*(authorization|bearer|password|secret|token|private[_-]?key|certificate|"
|
||||
+ "credential|payload|metadata).*");
|
||||
|
||||
private static final Pattern IPV4_WITH_PORT =
|
||||
Pattern.compile("\\b(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(:\\d{1,5})?\\b");
|
||||
|
||||
private GrpcDiagnosticsRedactor() {}
|
||||
|
||||
/** Whether a field name may appear in a snapshot at all. */
|
||||
public static boolean forbiddenField(String fieldName) {
|
||||
return fieldName != null && SENSITIVE_FIELD.matcher(fieldName).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks an address so two of them stay distinguishable without being resolvable.
|
||||
*
|
||||
* <p>The last two octets go; the first two stay, because "which subnet" is a real diagnostic
|
||||
* question and "which host" is not one the diagnostics endpoint should answer.
|
||||
*/
|
||||
public static String maskAddress(String address) {
|
||||
if (address == null || address.isBlank()) {
|
||||
return "unknown";
|
||||
}
|
||||
return IPV4_WITH_PORT
|
||||
.matcher(address)
|
||||
.replaceAll(matchResult -> matchResult.group(1) + "." + matchResult.group(2) + ".x.x");
|
||||
}
|
||||
|
||||
/**
|
||||
* A snapshot map with forbidden fields removed and addresses masked.
|
||||
*
|
||||
* @param addressFields which keys hold addresses, since a mask applied to everything would mangle
|
||||
* version strings and counters
|
||||
*/
|
||||
public static Map<String, String> redact(
|
||||
Map<String, String> raw, java.util.Set<String> addressFields) {
|
||||
if (raw == null || addressFields == null) {
|
||||
throw new IllegalArgumentException("redaction needs the map and the address field set");
|
||||
}
|
||||
Map<String, String> redacted = new LinkedHashMap<>();
|
||||
raw.forEach(
|
||||
(key, value) -> {
|
||||
if (forbiddenField(key)) {
|
||||
return;
|
||||
}
|
||||
redacted.put(key, addressFields.contains(key) ? maskAddress(value) : value);
|
||||
});
|
||||
return Map.copyOf(redacted);
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
package dev.caskeleton.grpc.advanced.diagnostics;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedFeatureFlags;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcChannelDiagnosticsPolicyTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
|
||||
|
||||
@Test
|
||||
@DisplayName("diagnostics need both an admin network and an admin role")
|
||||
void diagnosticsNeedBothGates() {
|
||||
GrpcChannelDiagnosticsPolicy policy = GrpcChannelDiagnosticsPolicy.standard();
|
||||
|
||||
assertThat(policy.mayRead("admin", Set.of("ROLE_PLATFORM_ADMIN"))).isTrue();
|
||||
assertThat(policy.mayRead("public", Set.of("ROLE_PLATFORM_ADMIN"))).isFalse();
|
||||
assertThat(policy.mayRead("admin", Set.of("ROLE_USER"))).isFalse();
|
||||
assertThatThrownBy(() -> new GrpcChannelDiagnosticsPolicy(Set.of("admin"), Set.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("either gate alone is the whole surface");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("CSDS is registered only when xDS is actually enabled")
|
||||
void csdsFollowsXds() {
|
||||
GrpcAdvancedFeatureFlags diagnosticsOnly =
|
||||
GrpcAdvancedFeatureFlags.forDevelopment()
|
||||
.enable(GrpcAdvancedCapability.CHANNEL_DIAGNOSTICS);
|
||||
GrpcAdvancedFeatureFlags withXds =
|
||||
GrpcAdvancedFeatureFlags.forDevelopment()
|
||||
.enable(GrpcAdvancedCapability.CHANNEL_DIAGNOSTICS)
|
||||
.enable(GrpcAdvancedCapability.XDS);
|
||||
|
||||
assertThat(GrpcChannelDiagnosticsPolicy.registerChannelz(diagnosticsOnly)).isTrue();
|
||||
assertThat(GrpcChannelDiagnosticsPolicy.registerCsds(diagnosticsOnly)).isFalse();
|
||||
assertThat(GrpcChannelDiagnosticsPolicy.registerCsds(withXds)).isTrue();
|
||||
assertThat(
|
||||
GrpcChannelDiagnosticsPolicy.registerChannelz(
|
||||
GrpcAdvancedFeatureFlags.forDevelopment()))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("credential, certificate, metadata and payload fields are dropped outright")
|
||||
void sensitiveFieldsAreDropped() {
|
||||
Map<String, String> raw = new LinkedHashMap<>();
|
||||
raw.put("connectivityState", "READY");
|
||||
raw.put("authorization", "Bearer abc");
|
||||
raw.put("peerCertificate", "-----BEGIN CERTIFICATE-----");
|
||||
raw.put("lastCallMetadata", "x-tenant=acme");
|
||||
raw.put("requestPayload", "{...}");
|
||||
|
||||
Map<String, String> redacted = GrpcDiagnosticsRedactor.redact(raw, Set.of());
|
||||
|
||||
assertThat(redacted).containsOnlyKeys("connectivityState");
|
||||
assertThat(GrpcDiagnosticsRedactor.forbiddenField("privateKeyRef")).isTrue();
|
||||
assertThat(GrpcDiagnosticsRedactor.forbiddenField("subchannelCount")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addresses are masked so two subchannels stay distinguishable but unresolvable")
|
||||
void addressesAreMaskedRatherThanDropped() {
|
||||
assertThat(GrpcDiagnosticsRedactor.maskAddress("10.4.13.201:9090")).isEqualTo("10.4.x.x");
|
||||
assertThat(GrpcDiagnosticsRedactor.maskAddress("10.9.13.201")).isEqualTo("10.9.x.x");
|
||||
assertThat(GrpcDiagnosticsRedactor.maskAddress(null)).isEqualTo("unknown");
|
||||
|
||||
Map<String, String> redacted =
|
||||
GrpcDiagnosticsRedactor.redact(
|
||||
Map.of("remoteAddress", "10.4.13.201:9090"), Set.of("remoteAddress"));
|
||||
assertThat(redacted).containsEntry("remoteAddress", "10.4.x.x");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a snapshot carrying an unmasked address is refused")
|
||||
void anUnmaskedAddressIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcChannelDiagnosticsSnapshot(
|
||||
"documents-read",
|
||||
"READY",
|
||||
3,
|
||||
100L,
|
||||
98L,
|
||||
2L,
|
||||
List.of("10.4.13.201:9090"),
|
||||
Map.of(),
|
||||
NOW))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("every tenant's connection");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a snapshot carries counters and states, and is accepted when masked")
|
||||
void aMaskedSnapshotIsAccepted() {
|
||||
GrpcChannelDiagnosticsSnapshot snapshot =
|
||||
new GrpcChannelDiagnosticsSnapshot(
|
||||
"documents-read",
|
||||
"READY",
|
||||
3,
|
||||
100L,
|
||||
98L,
|
||||
2L,
|
||||
List.of("10.4.x.x", "10.5.x.x"),
|
||||
Map.of("documents-cluster", "v7"),
|
||||
NOW);
|
||||
|
||||
assertThat(snapshot.subchannelCount()).isEqualTo(3);
|
||||
assertThat(snapshot.xdsResourceVersions()).containsEntry("documents-cluster", "v7");
|
||||
assertThat(snapshot.capturedAt()).isEqualTo(NOW);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a snapshot carrying a forbidden field name is refused")
|
||||
void aForbiddenFieldNameIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcChannelDiagnosticsSnapshot(
|
||||
"documents-read",
|
||||
"READY",
|
||||
1,
|
||||
1L,
|
||||
1L,
|
||||
0L,
|
||||
List.of(),
|
||||
Map.of("controlPlaneToken", "abc"),
|
||||
NOW))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("may not appear in a diagnostics snapshot");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the committed CSDS fixture is redacted into something publishable")
|
||||
void theCommittedCsdsFixtureIsRedacted() {
|
||||
String raw = resource("xds/control-plane-snapshot.json");
|
||||
|
||||
// The fixture deliberately contains the three shapes a snapshot must never carry, so the
|
||||
// redactor is exercised against data shaped like the real thing.
|
||||
assertThat(raw)
|
||||
.contains("controlPlaneToken")
|
||||
.contains("peerCertificate")
|
||||
.contains("10.4.13.201:9090");
|
||||
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
fields.put("connectivityState", "READY");
|
||||
fields.put("subchannelCount", "3");
|
||||
fields.put("controlPlaneToken", "must-not-appear-in-a-snapshot");
|
||||
fields.put("peerCertificate", "-----BEGIN CERTIFICATE-----");
|
||||
fields.put("lastCallMetadata", "x-tenant=acme");
|
||||
fields.put("remoteAddress", "10.4.13.201:9090");
|
||||
|
||||
Map<String, String> redacted = GrpcDiagnosticsRedactor.redact(fields, Set.of("remoteAddress"));
|
||||
|
||||
assertThat(redacted).containsOnlyKeys("connectivityState", "subchannelCount", "remoteAddress");
|
||||
assertThat(redacted.get("remoteAddress")).isEqualTo("10.4.x.x");
|
||||
assertThat(String.join("|", redacted.values())).doesNotContain("must-not-appear-in-a-snapshot");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a snapshot built from the fixture's resource versions is accepted")
|
||||
void aSnapshotFromTheFixtureIsAccepted() {
|
||||
assertThat(resource("xds/control-plane-snapshot.json")).contains("documents-cluster");
|
||||
|
||||
GrpcChannelDiagnosticsSnapshot snapshot =
|
||||
new GrpcChannelDiagnosticsSnapshot(
|
||||
"documents-read",
|
||||
"READY",
|
||||
3,
|
||||
100L,
|
||||
98L,
|
||||
2L,
|
||||
List.of("10.4.x.x", "10.4.x.x", "10.5.x.x"),
|
||||
Map.of("documents-cluster", "v7", "documents-route", "v7", "documents-listener", "v6"),
|
||||
NOW);
|
||||
|
||||
assertThat(snapshot.xdsResourceVersions()).hasSize(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("each capability names the real infrastructure its evidence depends on")
|
||||
void capabilitiesNameTheirInfrastructure() {
|
||||
assertThat(GrpcAdvancedInfrastructureTestkit.requiredFor(GrpcAdvancedCapability.GRPC_WEB))
|
||||
.containsExactly(GrpcAdvancedInfrastructureTestkit.Infrastructure.GRPC_WEB_PROXY);
|
||||
assertThat(GrpcAdvancedInfrastructureTestkit.requiredFor(GrpcAdvancedCapability.XDS))
|
||||
.containsExactly(GrpcAdvancedInfrastructureTestkit.Infrastructure.XDS_CONTROL_PLANE);
|
||||
assertThat(GrpcAdvancedInfrastructureTestkit.requiredFor(GrpcAdvancedCapability.HEDGING))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a suite without its infrastructure is reported as establishing nothing")
|
||||
void missingInfrastructureIsReported() {
|
||||
assertThat(
|
||||
GrpcAdvancedInfrastructureTestkit.missingInfrastructure(
|
||||
GrpcAdvancedCapability.XDS, Set.of()))
|
||||
.singleElement()
|
||||
.satisfies(gap -> assertThat(gap).contains("establishes nothing"));
|
||||
assertThat(
|
||||
GrpcAdvancedInfrastructureTestkit.missingInfrastructure(
|
||||
GrpcAdvancedCapability.XDS,
|
||||
Set.of(GrpcAdvancedInfrastructureTestkit.Infrastructure.XDS_CONTROL_PLANE)))
|
||||
.isEmpty();
|
||||
assertThat(
|
||||
GrpcAdvancedInfrastructureTestkit.missingInfrastructure(
|
||||
GrpcAdvancedCapability.HEDGING, Set.of()))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
private static String resource(String path) {
|
||||
try (java.io.InputStream stream =
|
||||
GrpcChannelDiagnosticsPolicyTest.class.getClassLoader().getResourceAsStream(path)) {
|
||||
if (stream == null) {
|
||||
throw new IllegalStateException("missing test resource " + path);
|
||||
}
|
||||
return new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
} catch (java.io.IOException e) {
|
||||
throw new java.io.UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"_comment": [
|
||||
"What CSDS reports for one client, as the diagnostics layer receives it before redaction.",
|
||||
"The fixture deliberately contains three things a snapshot must not publish - a control-plane",
|
||||
"token, a peer certificate and a raw socket address - so the redactor is tested against data",
|
||||
"shaped like the real thing rather than against a string somebody invented for the assertion."
|
||||
],
|
||||
"version_info": "v7",
|
||||
"resources": {
|
||||
"documents-cluster": "v7",
|
||||
"documents-route": "v7",
|
||||
"documents-listener": "v6"
|
||||
},
|
||||
"connectivityState": "READY",
|
||||
"subchannelCount": 3,
|
||||
"sockets": [
|
||||
{ "remoteAddress": "10.4.13.201:9090", "state": "READY" },
|
||||
{ "remoteAddress": "10.4.19.87:9090", "state": "READY" },
|
||||
{ "remoteAddress": "10.5.2.44:9090", "state": "TRANSIENT_FAILURE" }
|
||||
],
|
||||
"controlPlaneToken": "must-not-appear-in-a-snapshot",
|
||||
"peerCertificate": "-----BEGIN CERTIFICATE----- must-not-appear-in-a-snapshot",
|
||||
"lastCallMetadata": "x-tenant=acme"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
// Protobuf Edition lanes. Edition 2024 is an opt-in Advanced lane that must produce cross-consumer
|
||||
// compile evidence before anything public moves onto it; Edition 2026 is a watch lane that records
|
||||
// release/toolchain status and is refused as a Stable contract source.
|
||||
dependencies {
|
||||
api project(':grpc:grpc-core-api')
|
||||
api project(':grpc:grpc-proto-contract')
|
||||
api project(':grpc-advanced:grpc-advanced-bootstrap')
|
||||
}
|
||||
@@ -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
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package dev.caskeleton.grpc.advanced.edition;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Decides whether Edition 2024 may be adopted, and keeps its failures out of the Stable release.
|
||||
*
|
||||
* <p>The separation the Stable plan asks for, in one place: an Edition 2024 failure blocks the
|
||||
* edition's own promotion and does not block a proto3 release. Without that split, an opt-in lane
|
||||
* that nobody depends on can hold up every release, and the first response to that is to stop
|
||||
* running the lane.
|
||||
*/
|
||||
public final class GrpcEdition2024Gate {
|
||||
|
||||
private GrpcEdition2024Gate() {}
|
||||
|
||||
/**
|
||||
* Every reason Edition 2024 may not be promoted.
|
||||
*
|
||||
* @return an empty list when the edition is ready to adopt
|
||||
*/
|
||||
public static List<String> promotionBlockers(
|
||||
GrpcEditionCompatibilityReport report,
|
||||
boolean consumerMigrationPlanned,
|
||||
boolean promotionAdr) {
|
||||
if (report == null) {
|
||||
throw new IllegalArgumentException("a compatibility report is required");
|
||||
}
|
||||
List<String> blockers = new ArrayList<>(report.incompatibilities());
|
||||
if (!consumerMigrationPlanned) {
|
||||
blockers.add(
|
||||
"no consumer migration is planned; moving a public service to an edition breaks whichever "
|
||||
+ "consumer's generator treats its features differently");
|
||||
}
|
||||
if (!promotionAdr) {
|
||||
blockers.add(
|
||||
"no promotion ADR records the decision to move onto Edition "
|
||||
+ GrpcEdition2024Policy.EDITION);
|
||||
}
|
||||
return List.copyOf(blockers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an Edition 2024 failure blocks a proto3 Stable release.
|
||||
*
|
||||
* <p>Always false. Stated as a method so the property is tested rather than described.
|
||||
*/
|
||||
public static boolean blocksStableRelease() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an Edition 2024 failure blocks the edition's own promotion.
|
||||
*
|
||||
* <p>Always true, for the same reason.
|
||||
*/
|
||||
public static boolean blocksEditionPromotion() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package dev.caskeleton.grpc.advanced.edition;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Where Edition 2024 may be used, and where it may not.
|
||||
*
|
||||
* <p>Module-level opt-in, and never for a public service without a promotion decision. The reason
|
||||
* is that an edition change is invisible to the schema's owner and consequential for its consumers:
|
||||
* the wire bytes are usually identical, so nothing fails locally, and the breakage appears in
|
||||
* whichever consumer's generator handles the edition's features differently.
|
||||
*/
|
||||
public record GrpcEdition2024Policy(
|
||||
Set<String> optedInModules, Set<String> publicServices, boolean promotionApproved) {
|
||||
|
||||
/** The edition this policy governs. */
|
||||
public static final String EDITION = "2024";
|
||||
|
||||
/** Copies both sets and refuses an approval nobody recorded. */
|
||||
public GrpcEdition2024Policy {
|
||||
if (optedInModules == null || publicServices == null) {
|
||||
throw new IllegalArgumentException("an edition policy states both sets");
|
||||
}
|
||||
optedInModules = Set.copyOf(optedInModules);
|
||||
publicServices = Set.copyOf(publicServices);
|
||||
}
|
||||
|
||||
/** The default: nothing opted in, no promotion. */
|
||||
public static GrpcEdition2024Policy notAdopted() {
|
||||
return new GrpcEdition2024Policy(Set.of(), Set.of(), false);
|
||||
}
|
||||
|
||||
/** Whether {@code moduleId} may use Edition 2024 schema sources. */
|
||||
public boolean allowedIn(String moduleId) {
|
||||
return optedInModules.contains(moduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code serviceName} may move onto Edition 2024.
|
||||
*
|
||||
* <p>False for any public service until a promotion is approved, regardless of module opt-in: the
|
||||
* opt-in is a build decision and the promotion is a consumer-migration decision.
|
||||
*/
|
||||
public boolean serviceMayMove(String serviceName) {
|
||||
return !publicServices.contains(serviceName) || promotionApproved;
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.caskeleton.grpc.advanced.edition;
|
||||
|
||||
/**
|
||||
* Refuses Edition 2026 as a schema source, whatever a report says.
|
||||
*
|
||||
* <p>The guard is deliberately not conditional on the watch report. A watch lane's purpose is to
|
||||
* record what is true, and letting the same record also authorise use means the moment somebody
|
||||
* marks four fields SUPPORTED, a schema can move onto an edition with no promotion decision, no
|
||||
* consumer migration and no ADR. Turning the watch into a lane that can be used is a code change
|
||||
* here, and that is the point.
|
||||
*/
|
||||
public final class GrpcEdition2026Guard {
|
||||
|
||||
private GrpcEdition2026Guard() {}
|
||||
|
||||
/** Whether Edition 2026 may be used as a Stable contract source. Always false. */
|
||||
public static boolean allowedAsStableSource() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails when Edition 2026 is used as a schema source.
|
||||
*
|
||||
* @throws IllegalStateException naming what is still outstanding, so the refusal is actionable
|
||||
*/
|
||||
public static void requireNotUsedAsSource(GrpcEdition2026WatchReport report) {
|
||||
if (report == null) {
|
||||
throw new IllegalArgumentException("a watch report is required");
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Edition "
|
||||
+ GrpcEdition2026WatchReport.EDITION
|
||||
+ " is a watch lane, not a schema source"
|
||||
+ (report.outstanding().isEmpty()
|
||||
? "; every toolchain gate is satisfied, so the next step is a promotion decision "
|
||||
+ "rather than a schema change"
|
||||
: "; outstanding: " + report.outstanding()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an Edition 2026 CI failure blocks the Stable build.
|
||||
*
|
||||
* <p>False. A watch lane that can break the build is a watch lane somebody deletes.
|
||||
*/
|
||||
public static boolean blocksStableBuild() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.grpc.advanced.edition;
|
||||
|
||||
/**
|
||||
* The four independent things that have to be true before an edition is usable, tracked separately.
|
||||
*
|
||||
* <p>Separately, because they land at different times and in different projects. An edition can be
|
||||
* released by the specification while {@code protoc} does not emit it, or emitted while Buf cannot
|
||||
* lint it, or lintable while the Java runtime does not implement its features. A single "supported
|
||||
* yes/no" flag collapses four different waiting states into one, and the answer to "what are we
|
||||
* waiting for" is then nobody's.
|
||||
*/
|
||||
public enum GrpcEdition2026Status {
|
||||
/** Nothing is known yet. */
|
||||
UNKNOWN,
|
||||
/** Announced or drafted, not released. */
|
||||
DRAFT,
|
||||
/** Released by the specification. */
|
||||
RELEASED,
|
||||
/** Supported by the toolchain component in question. */
|
||||
SUPPORTED,
|
||||
/** Explicitly not supported, and not expected to be. */
|
||||
UNSUPPORTED;
|
||||
|
||||
/** Whether this status permits use. */
|
||||
public boolean usable() {
|
||||
return this == SUPPORTED;
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package dev.caskeleton.grpc.advanced.edition;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What is currently true about Edition 2026 across the four things that gate it.
|
||||
*
|
||||
* <p>Recorded with a date. A watch report without one is indistinguishable from a stale note, and
|
||||
* the whole purpose of a watch lane is to be re-read later by somebody deciding whether the wait is
|
||||
* over.
|
||||
*/
|
||||
public record GrpcEdition2026WatchReport(
|
||||
GrpcEdition2026Status specificationStatus,
|
||||
GrpcEdition2026Status protocStatus,
|
||||
GrpcEdition2026Status bufStatus,
|
||||
GrpcEdition2026Status javaRuntimeStatus,
|
||||
Instant observedAt) {
|
||||
|
||||
/** The edition this report tracks. */
|
||||
public static final String EDITION = "2026";
|
||||
|
||||
/** Requires every status and a date. */
|
||||
public GrpcEdition2026WatchReport {
|
||||
if (specificationStatus == null
|
||||
|| protocStatus == null
|
||||
|| bufStatus == null
|
||||
|| javaRuntimeStatus == null) {
|
||||
throw new IllegalArgumentException("a watch report records all four statuses");
|
||||
}
|
||||
if (observedAt == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"a watch report is dated; without a date it cannot be told from a stale note");
|
||||
}
|
||||
}
|
||||
|
||||
/** Nothing known yet, as of {@code observedAt}. */
|
||||
public static GrpcEdition2026WatchReport nothingKnown(Instant observedAt) {
|
||||
return new GrpcEdition2026WatchReport(
|
||||
GrpcEdition2026Status.UNKNOWN,
|
||||
GrpcEdition2026Status.UNKNOWN,
|
||||
GrpcEdition2026Status.UNKNOWN,
|
||||
GrpcEdition2026Status.UNKNOWN,
|
||||
observedAt);
|
||||
}
|
||||
|
||||
/** What is still missing, named. */
|
||||
public List<String> outstanding() {
|
||||
List<String> waiting = new ArrayList<>();
|
||||
if (!specificationStatus.usable()) {
|
||||
waiting.add("specification is " + specificationStatus);
|
||||
}
|
||||
if (!protocStatus.usable()) {
|
||||
waiting.add("protoc support is " + protocStatus);
|
||||
}
|
||||
if (!bufStatus.usable()) {
|
||||
waiting.add("Buf support is " + bufStatus);
|
||||
}
|
||||
if (!javaRuntimeStatus.usable()) {
|
||||
waiting.add("Java runtime support is " + javaRuntimeStatus);
|
||||
}
|
||||
return List.copyOf(waiting);
|
||||
}
|
||||
|
||||
/** Whether every gate is satisfied. */
|
||||
public boolean readyToEvaluate() {
|
||||
return outstanding().isEmpty();
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.caskeleton.grpc.advanced.edition;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* How an Edition schema compares with its proto3 twin, per consumer toolchain.
|
||||
*
|
||||
* <p>Three comparisons rather than one. Wire compatibility says stored and in-flight messages keep
|
||||
* decoding; JSON compatibility says a REST transcoder or a browser client keeps working; source
|
||||
* compatibility says the generated code still compiles. An edition migration can preserve the first
|
||||
* two and break the third for a language whose generator handles the edition's features differently
|
||||
* — which is exactly the failure this lane exists to find before a public service moves.
|
||||
*/
|
||||
public record GrpcEditionCompatibilityReport(
|
||||
String editionName,
|
||||
boolean wireCompatible,
|
||||
boolean jsonCompatible,
|
||||
Map<String, Boolean> sourceCompatibleByToolchain) {
|
||||
|
||||
/** Requires an edition and at least one toolchain result. */
|
||||
public GrpcEditionCompatibilityReport {
|
||||
if (editionName == null || editionName.isBlank()) {
|
||||
throw new IllegalArgumentException("a compatibility report names its edition");
|
||||
}
|
||||
if (sourceCompatibleByToolchain == null || sourceCompatibleByToolchain.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a report with no toolchain result compares nothing; Java alone is not cross-language "
|
||||
+ "evidence");
|
||||
}
|
||||
sourceCompatibleByToolchain = Map.copyOf(sourceCompatibleByToolchain);
|
||||
}
|
||||
|
||||
/** The toolchains whose generated code stopped compiling. */
|
||||
public Set<String> brokenToolchains() {
|
||||
return sourceCompatibleByToolchain.entrySet().stream()
|
||||
.filter(entry -> !entry.getValue())
|
||||
.map(Map.Entry::getKey)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
/** Every incompatibility, described. */
|
||||
public List<String> incompatibilities() {
|
||||
List<String> problems = new ArrayList<>();
|
||||
if (!wireCompatible) {
|
||||
problems.add(
|
||||
editionName + " is not wire-compatible with proto3; stored messages would break");
|
||||
}
|
||||
if (!jsonCompatible) {
|
||||
problems.add(
|
||||
editionName
|
||||
+ " is not JSON-compatible with proto3; transcoded and browser clients would break");
|
||||
}
|
||||
brokenToolchains().stream()
|
||||
.sorted()
|
||||
.forEach(
|
||||
toolchain ->
|
||||
problems.add(
|
||||
editionName
|
||||
+ " generated source does not compile for toolchain '"
|
||||
+ toolchain
|
||||
+ "'"));
|
||||
return List.copyOf(problems);
|
||||
}
|
||||
|
||||
/** Whether every comparison passed. */
|
||||
public boolean fullyCompatible() {
|
||||
return incompatibilities().isEmpty();
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
edition = "2024";
|
||||
|
||||
package hyeonworks.grpc.edition.v1;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "hyeonworks.grpc.edition.v1.generated";
|
||||
|
||||
// The Edition 2024 comparison fixture.
|
||||
//
|
||||
// It exists to be compiled beside its proto3 twin and compared: same fields, same numbers, same JSON
|
||||
// names, with presence expressed by the edition's features rather than by `optional`. The lane's
|
||||
// question is whether the two produce the same wire bytes and the same JSON, and answering it needs
|
||||
// both files to exist.
|
||||
//
|
||||
// Not a Stable contract source. No public service moves onto an edition until the lane has produced
|
||||
// cross-consumer compile evidence and a promotion ADR — see GrpcEdition2024Gate.
|
||||
message DocumentSummary {
|
||||
string id = 1;
|
||||
string title = 2 [features.field_presence = EXPLICIT];
|
||||
int64 revision = 3;
|
||||
repeated string labels = 4;
|
||||
}
|
||||
|
||||
enum DocumentState {
|
||||
DOCUMENT_STATE_UNSPECIFIED = 0;
|
||||
DOCUMENT_STATE_DRAFT = 1;
|
||||
DOCUMENT_STATE_PUBLISHED = 2;
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package dev.caskeleton.grpc.advanced.edition;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcEdition2024GateTest {
|
||||
|
||||
private static GrpcEditionCompatibilityReport report(
|
||||
boolean wire, boolean json, Map<String, Boolean> toolchains) {
|
||||
return new GrpcEditionCompatibilityReport("2024", wire, json, toolchains);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Edition 2024 is module-level opt-in, and nothing is opted in by default")
|
||||
void editionIsOptInPerModule() {
|
||||
GrpcEdition2024Policy policy = GrpcEdition2024Policy.notAdopted();
|
||||
|
||||
assertThat(policy.allowedIn("grpc-proto-contract")).isFalse();
|
||||
assertThat(
|
||||
new GrpcEdition2024Policy(Set.of("grpc-advanced-edition"), Set.of(), false)
|
||||
.allowedIn("grpc-advanced-edition"))
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a public service may not move onto the edition without a promotion")
|
||||
void publicServicesNeedAPromotion() {
|
||||
GrpcEdition2024Policy withoutPromotion =
|
||||
new GrpcEdition2024Policy(
|
||||
Set.of("grpc-advanced-edition"),
|
||||
Set.of("hyeonworks.document.v1.DocumentService"),
|
||||
false);
|
||||
GrpcEdition2024Policy withPromotion =
|
||||
new GrpcEdition2024Policy(
|
||||
Set.of("grpc-advanced-edition"),
|
||||
Set.of("hyeonworks.document.v1.DocumentService"),
|
||||
true);
|
||||
|
||||
assertThat(withoutPromotion.serviceMayMove("hyeonworks.document.v1.DocumentService")).isFalse();
|
||||
assertThat(withPromotion.serviceMayMove("hyeonworks.document.v1.DocumentService")).isTrue();
|
||||
assertThat(withoutPromotion.serviceMayMove("hyeonworks.internal.v1.ScratchService")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Java compiling alone is not cross-language evidence")
|
||||
void javaAloneIsNotEvidence() {
|
||||
GrpcEditionCompatibilityReport javaOnlyBroken =
|
||||
report(true, true, Map.of("java", true, "go", false, "python", true));
|
||||
|
||||
assertThat(javaOnlyBroken.fullyCompatible()).isFalse();
|
||||
assertThat(javaOnlyBroken.brokenToolchains()).containsExactly("go");
|
||||
assertThatThrownBy(() -> report(true, true, Map.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Java alone is not cross-language");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("wire, JSON and source compatibility are compared separately")
|
||||
void threeComparisonsAreSeparate() {
|
||||
assertThat(report(false, true, Map.of("java", true)).incompatibilities())
|
||||
.singleElement()
|
||||
.satisfies(problem -> assertThat(problem).contains("stored messages"));
|
||||
assertThat(report(true, false, Map.of("java", true)).incompatibilities())
|
||||
.singleElement()
|
||||
.satisfies(problem -> assertThat(problem).contains("browser clients"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("promotion needs compatibility, a consumer migration and an ADR")
|
||||
void promotionNeedsAllThree() {
|
||||
GrpcEditionCompatibilityReport clean = report(true, true, Map.of("java", true, "go", true));
|
||||
|
||||
assertThat(GrpcEdition2024Gate.promotionBlockers(clean, true, true)).isEmpty();
|
||||
assertThat(GrpcEdition2024Gate.promotionBlockers(clean, false, true))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("consumer migration"));
|
||||
assertThat(GrpcEdition2024Gate.promotionBlockers(clean, true, false))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("promotion ADR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an Edition failure blocks the edition's promotion and not a proto3 release")
|
||||
void editionFailuresAreIsolatedFromStable() {
|
||||
assertThat(GrpcEdition2024Gate.blocksStableRelease()).isFalse();
|
||||
assertThat(GrpcEdition2024Gate.blocksEditionPromotion()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the Edition 2024 comparison fixture ships beside its proto3 twin")
|
||||
void theComparisonFixtureShips() {
|
||||
String source = resource("proto/edition2024/compatibility.proto");
|
||||
|
||||
assertThat(source)
|
||||
.startsWith("edition = \"2024\";")
|
||||
.contains("features.field_presence = EXPLICIT")
|
||||
.contains("DOCUMENT_STATE_UNSPECIFIED = 0");
|
||||
}
|
||||
|
||||
private static String resource(String path) {
|
||||
try (InputStream stream =
|
||||
GrpcEdition2024GateTest.class.getClassLoader().getResourceAsStream(path)) {
|
||||
if (stream == null) {
|
||||
throw new IllegalStateException("missing resource " + path);
|
||||
}
|
||||
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package dev.caskeleton.grpc.advanced.edition;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcEdition2026GuardTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
|
||||
|
||||
@Test
|
||||
@DisplayName("the watch tracks four gates separately, because they land at different times")
|
||||
void theWatchTracksFourGates() {
|
||||
GrpcEdition2026WatchReport partial =
|
||||
new GrpcEdition2026WatchReport(
|
||||
GrpcEdition2026Status.RELEASED,
|
||||
GrpcEdition2026Status.DRAFT,
|
||||
GrpcEdition2026Status.UNKNOWN,
|
||||
GrpcEdition2026Status.UNSUPPORTED,
|
||||
NOW);
|
||||
|
||||
assertThat(partial.outstanding())
|
||||
.hasSize(4)
|
||||
.anySatisfy(item -> assertThat(item).contains("protoc support is DRAFT"))
|
||||
.anySatisfy(item -> assertThat(item).contains("Buf support is UNKNOWN"))
|
||||
.anySatisfy(item -> assertThat(item).contains("Java runtime support is UNSUPPORTED"));
|
||||
assertThat(partial.readyToEvaluate()).isFalse();
|
||||
assertThat(GrpcEdition2026WatchReport.nothingKnown(NOW).outstanding()).hasSize(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a watch report is dated, so it cannot be told from a stale note")
|
||||
void aWatchReportIsDated() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcEdition2026WatchReport(
|
||||
GrpcEdition2026Status.SUPPORTED,
|
||||
GrpcEdition2026Status.SUPPORTED,
|
||||
GrpcEdition2026Status.SUPPORTED,
|
||||
GrpcEdition2026Status.SUPPORTED,
|
||||
null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("stale note");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("only SUPPORTED counts as usable")
|
||||
void onlySupportedIsUsable() {
|
||||
assertThat(GrpcEdition2026Status.SUPPORTED.usable()).isTrue();
|
||||
assertThat(GrpcEdition2026Status.RELEASED.usable()).isFalse();
|
||||
assertThat(GrpcEdition2026Status.DRAFT.usable()).isFalse();
|
||||
assertThat(GrpcEdition2026Status.UNKNOWN.usable()).isFalse();
|
||||
assertThat(GrpcEdition2026Status.UNSUPPORTED.usable()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Edition 2026 is refused as a schema source even when every gate is satisfied")
|
||||
void theGuardIsNotConditionalOnTheReport() {
|
||||
GrpcEdition2026WatchReport allSupported =
|
||||
new GrpcEdition2026WatchReport(
|
||||
GrpcEdition2026Status.SUPPORTED,
|
||||
GrpcEdition2026Status.SUPPORTED,
|
||||
GrpcEdition2026Status.SUPPORTED,
|
||||
GrpcEdition2026Status.SUPPORTED,
|
||||
NOW);
|
||||
|
||||
assertThat(allSupported.readyToEvaluate()).isTrue();
|
||||
assertThat(GrpcEdition2026Guard.allowedAsStableSource()).isFalse();
|
||||
assertThatThrownBy(() -> GrpcEdition2026Guard.requireNotUsedAsSource(allSupported))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("promotion decision rather than a schema change");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a refusal names what is still outstanding, so it is actionable")
|
||||
void aRefusalNamesWhatIsOutstanding() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
GrpcEdition2026Guard.requireNotUsedAsSource(
|
||||
GrpcEdition2026WatchReport.nothingKnown(NOW)))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("outstanding:");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a watch lane failure does not break the Stable build")
|
||||
void theWatchLaneDoesNotBlockTheStableBuild() {
|
||||
assertThat(GrpcEdition2026Guard.blocksStableBuild()).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
// Resilience and discovery capabilities that Stable refuses: read-only unary hedging, the custom
|
||||
// name resolver SPI, the custom load balancer SPI, and the proxyless xDS experimental profile.
|
||||
dependencies {
|
||||
api project(':grpc:grpc-core-api')
|
||||
api project(':grpc:grpc-policy')
|
||||
api project(':grpc:grpc-client')
|
||||
api project(':grpc:grpc-discovery')
|
||||
api project(':grpc-advanced:grpc-advanced-bootstrap')
|
||||
}
|
||||
@@ -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=
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package dev.caskeleton.grpc.advanced.discovery;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* A custom name resolver, with the safety rules applied on the way in.
|
||||
*
|
||||
* <p>Everything an update can do wrong is checked here rather than by the listener, because the
|
||||
* listener is the channel and the channel will believe whatever it is told. Stale revisions and
|
||||
* empty endpoint sets are dropped rather than propagated, and once closed the resolver accepts
|
||||
* nothing at all.
|
||||
*/
|
||||
public final class GrpcCustomResolver implements AutoCloseable {
|
||||
|
||||
private final String authority;
|
||||
private final Consumer<GrpcResolverUpdate> listener;
|
||||
private final AtomicReference<GrpcEndpointSnapshot> applied = new AtomicReference<>();
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
|
||||
/** Binds a resolver to the authority it resolves and the listener it feeds. */
|
||||
public GrpcCustomResolver(String authority, Consumer<GrpcResolverUpdate> listener) {
|
||||
if (authority == null || authority.isBlank()) {
|
||||
throw new IllegalArgumentException("a resolver names the authority it resolves");
|
||||
}
|
||||
if (listener == null) {
|
||||
throw new IllegalArgumentException("a resolver needs a listener to deliver updates to");
|
||||
}
|
||||
this.authority = authority;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers an update.
|
||||
*
|
||||
* @return the violations that stopped it, empty when it was applied
|
||||
*/
|
||||
public List<String> offer(GrpcResolverUpdate update) {
|
||||
if (closed.get()) {
|
||||
return List.of(
|
||||
"the resolver is closed; an update after close resurrects routing for a channel nobody "
|
||||
+ "is using");
|
||||
}
|
||||
List<String> violations = GrpcResolverSafetyPolicy.violations(update, applied.get());
|
||||
if (!violations.isEmpty()) {
|
||||
return violations;
|
||||
}
|
||||
applied.set(update.snapshot());
|
||||
listener.accept(update);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/** The snapshot currently in force. */
|
||||
public Optional<GrpcEndpointSnapshot> currentSnapshot() {
|
||||
return Optional.ofNullable(applied.get());
|
||||
}
|
||||
|
||||
/** The authority this resolver answers for. */
|
||||
public String authority() {
|
||||
return authority;
|
||||
}
|
||||
|
||||
/** Whether the resolver has been closed. */
|
||||
public boolean closed() {
|
||||
return closed.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closed.set(true);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.grpc.advanced.discovery;
|
||||
|
||||
/**
|
||||
* One endpoint a picker may choose, described only by what routing is allowed to consider.
|
||||
*
|
||||
* <p>The field list is the allowlist. Health, connectivity, weight and ejection are properties of
|
||||
* the endpoint; a tenant id or a request field is a property of the caller, and routing on those
|
||||
* turns a load balancer into a router with an authorization decision buried in it.
|
||||
*/
|
||||
public record GrpcEndpointCandidate(
|
||||
String address, boolean healthy, boolean connected, int weight, boolean ejected) {
|
||||
|
||||
/** Requires an address and a sane weight. */
|
||||
public GrpcEndpointCandidate {
|
||||
if (address == null || address.isBlank()) {
|
||||
throw new IllegalArgumentException("an endpoint candidate needs an address");
|
||||
}
|
||||
if (weight < 0) {
|
||||
throw new IllegalArgumentException("a weight must not be negative");
|
||||
}
|
||||
if (weight > 1000) {
|
||||
throw new IllegalArgumentException(
|
||||
"a weight above 1000 is a scale nobody can reason about against the others");
|
||||
}
|
||||
}
|
||||
|
||||
/** A healthy, connected endpoint at the default weight. */
|
||||
public static GrpcEndpointCandidate ready(String address) {
|
||||
return new GrpcEndpointCandidate(address, true, true, 100, false);
|
||||
}
|
||||
|
||||
/** Whether this endpoint may receive a request. */
|
||||
public boolean selectable() {
|
||||
return healthy && connected && !ejected && weight > 0;
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.caskeleton.grpc.advanced.discovery;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* One resolver update: a monotonic revision and the complete endpoint set at that revision.
|
||||
*
|
||||
* <p>Complete, not a delta. A delta protocol needs both sides to agree on what they last saw, and a
|
||||
* resolver that reconnects to its discovery source has no way to establish that; a full set at each
|
||||
* revision makes a missed update harmless.
|
||||
*
|
||||
* <p>The revision is what makes a late update safe to drop. Without it, an update that arrives out
|
||||
* of order replaces newer endpoints with older ones, and the channel routes to instances that were
|
||||
* removed.
|
||||
*/
|
||||
public record GrpcEndpointSnapshot(long revision, String authority, List<String> endpoints) {
|
||||
|
||||
/** Requires a positive revision, an authority and a non-empty endpoint set. */
|
||||
public GrpcEndpointSnapshot {
|
||||
if (revision < 1) {
|
||||
throw new IllegalArgumentException("resolver revisions are 1-based; got " + revision);
|
||||
}
|
||||
if (authority == null || authority.isBlank()) {
|
||||
throw new IllegalArgumentException("a snapshot names the authority it resolves");
|
||||
}
|
||||
if (endpoints == null || endpoints.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"an empty endpoint set is refused; a resolver that reports zero endpoints during its own "
|
||||
+ "outage would take the channel down with it");
|
||||
}
|
||||
if (endpoints.stream().anyMatch(endpoint -> endpoint == null || endpoint.isBlank())) {
|
||||
throw new IllegalArgumentException("every endpoint must be a non-blank address");
|
||||
}
|
||||
endpoints = List.copyOf(endpoints);
|
||||
if (Set.copyOf(endpoints).size() != endpoints.size()) {
|
||||
throw new IllegalArgumentException(
|
||||
"duplicate endpoints skew a round-robin picker towards whichever address is repeated");
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this snapshot supersedes {@code other}. */
|
||||
public boolean supersedes(GrpcEndpointSnapshot other) {
|
||||
return other == null || (authority.equals(other.authority()) && revision > other.revision());
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package dev.caskeleton.grpc.advanced.discovery;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Which endpoint a picker chose, or why it chose none.
|
||||
*
|
||||
* <p>{@link Verdict#DETERMINISTIC_FALLBACK} is separate from {@link Verdict#NO_ENDPOINT_AVAILABLE}
|
||||
* because they say different things about the picker. The first means custom logic failed and the
|
||||
* platform took over, which is a defect to fix; the second means there was genuinely nowhere to
|
||||
* send the request, which is an outage. A picker that reports both the same way hides its own bugs
|
||||
* inside the backend's.
|
||||
*/
|
||||
public record GrpcLoadBalancerDecision(
|
||||
Verdict verdict, Optional<GrpcEndpointCandidate> chosen, String reason) {
|
||||
|
||||
/** What the picker decided. */
|
||||
public enum Verdict {
|
||||
/** The picker chose an endpoint. */
|
||||
PICKED,
|
||||
/** The picker failed; the platform chose deterministically instead. */
|
||||
DETERMINISTIC_FALLBACK,
|
||||
/** Nothing was selectable. */
|
||||
NO_ENDPOINT_AVAILABLE
|
||||
}
|
||||
|
||||
/** Requires an endpoint on the two verdicts that have one. */
|
||||
public GrpcLoadBalancerDecision {
|
||||
if (verdict == null || chosen == null) {
|
||||
throw new IllegalArgumentException("a picker decision has a verdict and the Optional");
|
||||
}
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new IllegalArgumentException("a picker decision explains itself");
|
||||
}
|
||||
if (verdict == Verdict.NO_ENDPOINT_AVAILABLE && chosen.isPresent()) {
|
||||
throw new IllegalArgumentException("a decision with no endpoint available carries none");
|
||||
}
|
||||
if (verdict != Verdict.NO_ENDPOINT_AVAILABLE && chosen.isEmpty()) {
|
||||
throw new IllegalArgumentException("a decision that picked carries the endpoint it picked");
|
||||
}
|
||||
}
|
||||
|
||||
/** The picker's own choice. */
|
||||
public static GrpcLoadBalancerDecision picked(GrpcEndpointCandidate endpoint, String reason) {
|
||||
return new GrpcLoadBalancerDecision(Verdict.PICKED, Optional.of(endpoint), reason);
|
||||
}
|
||||
|
||||
/** The platform's fallback after a picker failure. */
|
||||
public static GrpcLoadBalancerDecision fallback(GrpcEndpointCandidate endpoint, String reason) {
|
||||
return new GrpcLoadBalancerDecision(
|
||||
Verdict.DETERMINISTIC_FALLBACK, Optional.of(endpoint), reason);
|
||||
}
|
||||
|
||||
/** Nothing was selectable. */
|
||||
public static GrpcLoadBalancerDecision none(String reason) {
|
||||
return new GrpcLoadBalancerDecision(Verdict.NO_ENDPOINT_AVAILABLE, Optional.empty(), reason);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.caskeleton.grpc.advanced.discovery;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Chooses one endpoint from the candidates the resolver supplied.
|
||||
*
|
||||
* <p>The signature is the safety property. A picker receives a list of candidates and nothing else:
|
||||
* no request, no metadata, no caller. It therefore cannot route on a tenant, and the rule "business
|
||||
* data is not a routing input" is enforced by there being no business data to reach.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GrpcLoadBalancerPicker {
|
||||
|
||||
/**
|
||||
* Picks an endpoint.
|
||||
*
|
||||
* @param candidates the selectable endpoints, never empty
|
||||
* @return the chosen endpoint, which must be one of {@code candidates}
|
||||
*/
|
||||
GrpcEndpointCandidate pick(List<GrpcEndpointCandidate> candidates);
|
||||
|
||||
/** Round-robin, as the deterministic default and the fallback. */
|
||||
static GrpcLoadBalancerPicker roundRobin() {
|
||||
java.util.concurrent.atomic.AtomicInteger cursor =
|
||||
new java.util.concurrent.atomic.AtomicInteger();
|
||||
return candidates -> candidates.get(Math.floorMod(cursor.getAndIncrement(), candidates.size()));
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package dev.caskeleton.grpc.advanced.discovery;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Runs a custom picker and refuses to let it do something the resolver did not authorise.
|
||||
*
|
||||
* <p>Two rules. A picker may only return an endpoint the resolver supplied, because a picker that
|
||||
* can invent an address can send a request anywhere; and a picker that throws produces a
|
||||
* deterministic fallback rather than a failed call, because a picker bug should degrade the
|
||||
* balancing rather than the availability.
|
||||
*
|
||||
* <p>Weighted and load-aware pickers are not refused here, but the plan requires evidence before
|
||||
* they ship: {@link #requiresLoadEvidence} names which shapes those are.
|
||||
*/
|
||||
public final class GrpcLoadBalancerSafetyPolicy {
|
||||
|
||||
private final GrpcLoadBalancerPicker picker;
|
||||
private final GrpcLoadBalancerPicker fallback;
|
||||
|
||||
/** Wraps a custom picker with the platform's deterministic fallback. */
|
||||
public GrpcLoadBalancerSafetyPolicy(GrpcLoadBalancerPicker picker) {
|
||||
this(picker, GrpcLoadBalancerPicker.roundRobin());
|
||||
}
|
||||
|
||||
/** Wraps a custom picker with an explicit fallback. */
|
||||
public GrpcLoadBalancerSafetyPolicy(
|
||||
GrpcLoadBalancerPicker picker, GrpcLoadBalancerPicker fallback) {
|
||||
if (picker == null || fallback == null) {
|
||||
throw new IllegalArgumentException("a safety policy needs a picker and a fallback");
|
||||
}
|
||||
this.picker = picker;
|
||||
this.fallback = fallback;
|
||||
}
|
||||
|
||||
/** Picks an endpoint, or explains why none was chosen. */
|
||||
public GrpcLoadBalancerDecision pick(List<GrpcEndpointCandidate> candidates) {
|
||||
if (candidates == null) {
|
||||
throw new IllegalArgumentException("a candidate list is required");
|
||||
}
|
||||
List<GrpcEndpointCandidate> selectable =
|
||||
candidates.stream().filter(GrpcEndpointCandidate::selectable).toList();
|
||||
if (selectable.isEmpty()) {
|
||||
return GrpcLoadBalancerDecision.none(
|
||||
"no endpoint is healthy, connected, un-ejected and non-zero weight");
|
||||
}
|
||||
GrpcEndpointCandidate chosen;
|
||||
try {
|
||||
chosen = picker.pick(selectable);
|
||||
} catch (RuntimeException pickerFailure) {
|
||||
return GrpcLoadBalancerDecision.fallback(
|
||||
fallback.pick(selectable),
|
||||
"the custom picker threw ("
|
||||
+ pickerFailure.getClass().getSimpleName()
|
||||
+ "); falling back deterministically rather than failing the call");
|
||||
}
|
||||
if (chosen == null || !selectable.contains(chosen)) {
|
||||
return GrpcLoadBalancerDecision.fallback(
|
||||
fallback.pick(selectable),
|
||||
"the custom picker returned an endpoint the resolver did not supply; a picker that can "
|
||||
+ "invent an address can send a request anywhere");
|
||||
}
|
||||
return GrpcLoadBalancerDecision.picked(chosen, "chosen by the custom picker");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a picker of this shape needs performance, fairness and failover evidence before it
|
||||
* ships.
|
||||
*
|
||||
* @param loadAware whether the picker uses reported load or latency
|
||||
* @param weighted whether the picker uses endpoint weights
|
||||
*/
|
||||
public static boolean requiresLoadEvidence(boolean loadAware, boolean weighted) {
|
||||
return loadAware || weighted;
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package dev.caskeleton.grpc.advanced.discovery;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* What a custom resolver is allowed to say, and what it may never carry.
|
||||
*
|
||||
* <p>A resolver runs inside the channel and speaks to something outside the deployment. Everything
|
||||
* it can put into an update is therefore attacker-influenced in the worst case and
|
||||
* operator-influenced in the ordinary one, so the safety rules are about limiting what an update
|
||||
* can change: addresses and a validated service config, never a credential and never business
|
||||
* metadata.
|
||||
*/
|
||||
public final class GrpcResolverSafetyPolicy {
|
||||
|
||||
private static final Pattern AUTHORITY =
|
||||
Pattern.compile("[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\\d{1,5})?");
|
||||
|
||||
private static final Pattern CREDENTIAL_SHAPED =
|
||||
Pattern.compile("(?i).*(authorization|bearer|password|secret|token|api[_-]?key).*");
|
||||
|
||||
private GrpcResolverSafetyPolicy() {}
|
||||
|
||||
/**
|
||||
* Every problem with an update, given what was last accepted.
|
||||
*
|
||||
* @param lastAccepted the newest snapshot already applied, or null when none has been
|
||||
* @return an empty list when the update is safe to apply
|
||||
*/
|
||||
public static List<String> violations(
|
||||
GrpcResolverUpdate update, GrpcEndpointSnapshot lastAccepted) {
|
||||
if (update == null) {
|
||||
throw new IllegalArgumentException("an update is required");
|
||||
}
|
||||
List<String> violations = new ArrayList<>();
|
||||
GrpcEndpointSnapshot snapshot = update.snapshot();
|
||||
|
||||
if (!AUTHORITY.matcher(snapshot.authority()).matches()) {
|
||||
violations.add(
|
||||
"authority '"
|
||||
+ snapshot.authority()
|
||||
+ "' is not a plain host or host:port; a resolver that can change the authority can "
|
||||
+ "change which certificate the channel accepts");
|
||||
}
|
||||
if (!snapshot.supersedes(lastAccepted)) {
|
||||
violations.add(
|
||||
"revision "
|
||||
+ snapshot.revision()
|
||||
+ " does not supersede the applied revision "
|
||||
+ (lastAccepted == null ? "none" : lastAccepted.revision())
|
||||
+ "; applying it would replace newer endpoints with older ones");
|
||||
}
|
||||
update
|
||||
.serviceConfigJson()
|
||||
.ifPresent(
|
||||
config -> {
|
||||
if (CREDENTIAL_SHAPED.matcher(config).find()) {
|
||||
violations.add(
|
||||
"the pushed service config contains a credential-shaped field; a resolver "
|
||||
+ "supplies addresses and policy, never authentication material");
|
||||
}
|
||||
});
|
||||
return List.copyOf(violations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a closed resolver's update should be applied.
|
||||
*
|
||||
* <p>Always false. A resolver that keeps delivering after close is one whose discovery source has
|
||||
* not noticed the channel is gone, and applying its updates resurrects routing for a channel
|
||||
* nobody is using.
|
||||
*/
|
||||
public static boolean acceptAfterClose() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether a resolver may supply caller identity or business metadata. Always false. */
|
||||
public static boolean mayCarryBusinessMetadata() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The service config an update may contribute, once validated. */
|
||||
public static Optional<String> acceptedServiceConfig(
|
||||
GrpcResolverUpdate update, GrpcEndpointSnapshot lastAccepted) {
|
||||
return violations(update, lastAccepted).isEmpty()
|
||||
? update.serviceConfigJson()
|
||||
: Optional.empty();
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.grpc.advanced.discovery;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A resolver's report: endpoints, and optionally the service config that goes with them.
|
||||
*
|
||||
* <p>The service config is optional and, when present, is validated as if a human had written it. A
|
||||
* resolver that can push retry policy is a resolver that can turn on retries for a non-idempotent
|
||||
* method from outside the codebase, and the fact that a control plane sent it is not evidence that
|
||||
* anyone reviewed it.
|
||||
*/
|
||||
public record GrpcResolverUpdate(
|
||||
GrpcEndpointSnapshot snapshot, Optional<String> serviceConfigJson) {
|
||||
|
||||
/** Requires a snapshot and the Optional. */
|
||||
public GrpcResolverUpdate {
|
||||
if (snapshot == null || serviceConfigJson == null) {
|
||||
throw new IllegalArgumentException("a resolver update carries a snapshot and the Optional");
|
||||
}
|
||||
serviceConfigJson.ifPresent(
|
||||
config -> {
|
||||
if (config.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a present service config must not be blank; absent and empty are different states");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** An update with endpoints only. */
|
||||
public static GrpcResolverUpdate endpointsOnly(GrpcEndpointSnapshot snapshot) {
|
||||
return new GrpcResolverUpdate(snapshot, Optional.empty());
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package dev.caskeleton.grpc.advanced.resilience;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Caps duplicate attempts as a fraction of real traffic.
|
||||
*
|
||||
* <p>Necessary for the same reason a retry budget is, and more urgently. A retry happens after a
|
||||
* failure; a hedge happens on a call that might have succeeded, so a fleet that hedges without a
|
||||
* budget doubles its backend load in the steady state and doubles it again the moment latency
|
||||
* rises.
|
||||
*/
|
||||
public final class GrpcHedgingBudget {
|
||||
|
||||
private final long maxTokens;
|
||||
private final long tokensPerHedge;
|
||||
private final AtomicLong tokens;
|
||||
|
||||
/**
|
||||
* A budget that starts full.
|
||||
*
|
||||
* @param ratio hedges permitted per completed call, e.g. 0.1 for one hedge in ten
|
||||
* @param maxTokens how much credit may accumulate, which bounds a burst after a quiet period
|
||||
*/
|
||||
public static GrpcHedgingBudget of(double ratio, long maxTokens) {
|
||||
if (ratio <= 0.0d || ratio > 0.5d) {
|
||||
throw new IllegalArgumentException(
|
||||
"a hedging ratio above 0.5 means more than half of all calls are duplicated, which is a "
|
||||
+ "load decision rather than a latency one");
|
||||
}
|
||||
if (maxTokens < 1) {
|
||||
throw new IllegalArgumentException("a budget needs at least one token");
|
||||
}
|
||||
return new GrpcHedgingBudget(maxTokens, Math.round(1.0d / ratio));
|
||||
}
|
||||
|
||||
private GrpcHedgingBudget(long maxTokens, long tokensPerHedge) {
|
||||
this.maxTokens = maxTokens;
|
||||
this.tokensPerHedge = tokensPerHedge;
|
||||
this.tokens = new AtomicLong(maxTokens);
|
||||
}
|
||||
|
||||
/** Takes the credit for one hedge, if there is any. */
|
||||
public boolean tryConsume() {
|
||||
while (true) {
|
||||
long observed = tokens.get();
|
||||
if (observed < tokensPerHedge) {
|
||||
return false;
|
||||
}
|
||||
if (tokens.compareAndSet(observed, observed - tokensPerHedge)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Records a completed call, which earns credit back. */
|
||||
public void recordCompletion() {
|
||||
tokens.updateAndGet(observed -> Math.min(maxTokens, observed + 1L));
|
||||
}
|
||||
|
||||
/** How much credit is left. */
|
||||
public long availableTokens() {
|
||||
return tokens.get();
|
||||
}
|
||||
|
||||
/** Whether another hedge could be afforded. */
|
||||
public boolean exhausted() {
|
||||
return tokens.get() < tokensPerHedge;
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.caskeleton.grpc.advanced.resilience;
|
||||
|
||||
import dev.caskeleton.grpc.core.RpcType;
|
||||
import dev.caskeleton.grpc.policy.GrpcMethodPolicy;
|
||||
import dev.caskeleton.grpc.policy.RpcIdempotencyProfile;
|
||||
import dev.caskeleton.grpc.resilience.GrpcRetryOwner;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Whether a method may be hedged at all.
|
||||
*
|
||||
* <p>Read-only unary, and nothing else. A hedged mutation runs twice by design rather than by
|
||||
* accident — both attempts are in flight, both may reach the server, and an idempotency key does
|
||||
* not help because the second attempt is not a retry of a failure but a duplicate of a success in
|
||||
* progress. A hedged stream is worse still: two streams deliver two prefixes.
|
||||
*/
|
||||
public final class GrpcHedgingEligibility {
|
||||
|
||||
private GrpcHedgingEligibility() {}
|
||||
|
||||
/**
|
||||
* Why {@code policy} may not be hedged, or empty when it may.
|
||||
*
|
||||
* @return a refusal reason, or empty when hedging is permitted
|
||||
*/
|
||||
public static Optional<String> refusalReason(GrpcMethodPolicy policy, GrpcRetryOwner retryOwner) {
|
||||
if (policy == null || retryOwner == null) {
|
||||
throw new IllegalArgumentException("eligibility needs a method policy and a retry owner");
|
||||
}
|
||||
if (policy.rpcType() != RpcType.UNARY) {
|
||||
return Optional.of(
|
||||
"method '"
|
||||
+ policy.method().canonical()
|
||||
+ "' is "
|
||||
+ policy.rpcType()
|
||||
+ "; two hedged streams deliver two prefixes");
|
||||
}
|
||||
if (policy.idempotency() != RpcIdempotencyProfile.READ_ONLY) {
|
||||
return Optional.of(
|
||||
"method '"
|
||||
+ policy.method().canonical()
|
||||
+ "' is "
|
||||
+ policy.idempotency()
|
||||
+ "; a hedged mutation runs twice by design, and an idempotency key does not help "
|
||||
+ "because the second attempt duplicates a success in progress rather than retrying a "
|
||||
+ "failure");
|
||||
}
|
||||
if (!retryOwner.hedgingAllowed()) {
|
||||
return Optional.of(
|
||||
"retry owner is " + retryOwner + ", which does not permit in-process hedging");
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Whether {@code policy} may be hedged. */
|
||||
public static boolean eligible(GrpcMethodPolicy policy, GrpcRetryOwner retryOwner) {
|
||||
return refusalReason(policy, retryOwner).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails when a method may not be hedged.
|
||||
*
|
||||
* @throws IllegalStateException with the reason
|
||||
*/
|
||||
public static void require(GrpcMethodPolicy policy, GrpcRetryOwner retryOwner) {
|
||||
refusalReason(policy, retryOwner)
|
||||
.ifPresent(
|
||||
reason -> {
|
||||
throw new IllegalStateException("hedging refused: " + reason);
|
||||
});
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package dev.caskeleton.grpc.advanced.resilience;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Duplicate in-flight attempts for a read, and the two bounds that keep them affordable.
|
||||
*
|
||||
* <p>Hedging trades backend load for tail latency: a second attempt goes out before the first has
|
||||
* failed, so a slow replica stops mattering. The cost is that every hedged call may cost two, and
|
||||
* it costs two precisely when the backend is already slow — which is why the attempt cap starts at
|
||||
* 2 and the delay is required to be meaningfully above the median.
|
||||
*/
|
||||
public record GrpcHedgingPolicy(int maxAttempts, Duration hedgingDelay, Duration totalDeadline) {
|
||||
|
||||
/** The initial cap. Raising it is a deliberate decision with load evidence behind it. */
|
||||
public static final int INITIAL_MAX_ATTEMPTS = 2;
|
||||
|
||||
/** Refuses a policy whose duplicate load is unbounded or whose delay is meaningless. */
|
||||
public GrpcHedgingPolicy {
|
||||
if (maxAttempts < 2) {
|
||||
throw new IllegalArgumentException("hedging means at least two attempts; got " + maxAttempts);
|
||||
}
|
||||
if (maxAttempts > INITIAL_MAX_ATTEMPTS) {
|
||||
throw new IllegalArgumentException(
|
||||
"hedging is capped at "
|
||||
+ INITIAL_MAX_ATTEMPTS
|
||||
+ " attempts until load evidence justifies more; each extra attempt multiplies "
|
||||
+ "backend load exactly when the backend is already slow");
|
||||
}
|
||||
if (hedgingDelay == null || hedgingDelay.isNegative()) {
|
||||
throw new IllegalArgumentException("a hedging delay must be present and non-negative");
|
||||
}
|
||||
if (hedgingDelay.isZero()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a zero hedging delay sends every attempt at once, which doubles load for every call "
|
||||
+ "rather than for the slow ones");
|
||||
}
|
||||
if (totalDeadline == null || totalDeadline.isZero() || totalDeadline.isNegative()) {
|
||||
throw new IllegalArgumentException("hedging needs a total deadline to fit inside");
|
||||
}
|
||||
if (hedgingDelay.compareTo(totalDeadline) >= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"the hedging delay is at or above the total deadline, so the second attempt never starts");
|
||||
}
|
||||
}
|
||||
|
||||
/** A policy that hedges once after {@code hedgingDelay}. */
|
||||
public static GrpcHedgingPolicy hedgeOnce(Duration hedgingDelay, Duration totalDeadline) {
|
||||
return new GrpcHedgingPolicy(INITIAL_MAX_ATTEMPTS, hedgingDelay, totalDeadline);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.grpc.advanced.resilience;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* What a hedged call cost and what it saved.
|
||||
*
|
||||
* <p>Both numbers, because hedging is a trade and a dashboard that shows only the latency
|
||||
* improvement makes it look free. {@code duplicateBackendCalls} is what the backend team sees, and
|
||||
* {@code cancelledLoserAttempts} is how much of that work was thrown away.
|
||||
*/
|
||||
public record GrpcHedgingResult(
|
||||
int attemptsIssued,
|
||||
int winningAttempt,
|
||||
int cancelledLoserAttempts,
|
||||
int duplicateBackendCalls,
|
||||
Duration observedLatency) {
|
||||
|
||||
/** Requires coherent counts. */
|
||||
public GrpcHedgingResult {
|
||||
if (attemptsIssued < 1) {
|
||||
throw new IllegalArgumentException("a hedged call issues at least one attempt");
|
||||
}
|
||||
if (winningAttempt < 1 || winningAttempt > attemptsIssued) {
|
||||
throw new IllegalArgumentException("the winning attempt is one of the attempts issued");
|
||||
}
|
||||
if (cancelledLoserAttempts < 0 || cancelledLoserAttempts > attemptsIssued - 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"at most every attempt but the winner can be a cancelled loser");
|
||||
}
|
||||
if (duplicateBackendCalls < 0 || duplicateBackendCalls > attemptsIssued - 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"duplicate backend calls are the attempts beyond the first");
|
||||
}
|
||||
if (observedLatency == null || observedLatency.isNegative()) {
|
||||
throw new IllegalArgumentException("a hedged call records its latency");
|
||||
}
|
||||
}
|
||||
|
||||
/** A call that did not need to hedge. */
|
||||
public static GrpcHedgingResult firstAttemptWon(Duration latency) {
|
||||
return new GrpcHedgingResult(1, 1, 0, 0, latency);
|
||||
}
|
||||
|
||||
/** Whether this call actually issued a duplicate. */
|
||||
public boolean hedged() {
|
||||
return attemptsIssued > 1;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package dev.caskeleton.grpc.advanced.xds;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* What happens when the control plane goes away.
|
||||
*
|
||||
* <p>Last-known-good, with a bound. Serving forever from a stale snapshot means a decommissioned
|
||||
* backend keeps receiving traffic indefinitely; failing immediately means a control-plane restart
|
||||
* takes every client down with it. The bound is where the deployment decides which risk it prefers,
|
||||
* and it has to be stated rather than inherited.
|
||||
*/
|
||||
public record GrpcXdsFailurePolicy(
|
||||
Duration maxStaleness, boolean failFastOnMissingResource, Duration initialFetchTimeout) {
|
||||
|
||||
/** Refuses a policy without a staleness bound. */
|
||||
public GrpcXdsFailurePolicy {
|
||||
if (maxStaleness == null || maxStaleness.isZero() || maxStaleness.isNegative()) {
|
||||
throw new IllegalArgumentException(
|
||||
"last-known-good needs a staleness bound; without one a decommissioned backend keeps "
|
||||
+ "receiving traffic indefinitely");
|
||||
}
|
||||
if (initialFetchTimeout == null
|
||||
|| initialFetchTimeout.isZero()
|
||||
|| initialFetchTimeout.isNegative()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a client with no snapshot yet needs a bound on how long it waits before failing");
|
||||
}
|
||||
}
|
||||
|
||||
/** The default: fifteen minutes of last-known-good, fail fast on a resource that vanished. */
|
||||
public static GrpcXdsFailurePolicy standard() {
|
||||
return new GrpcXdsFailurePolicy(Duration.ofMinutes(15), true, Duration.ofSeconds(15));
|
||||
}
|
||||
|
||||
/** What a client should do given the newest snapshot it holds. */
|
||||
public Decision decide(Optional<GrpcXdsResourceSnapshot> snapshot, Instant now) {
|
||||
if (snapshot == null || now == null) {
|
||||
throw new IllegalArgumentException("a decision needs the snapshot Optional and a moment");
|
||||
}
|
||||
if (snapshot.isEmpty()) {
|
||||
return Decision.NO_SNAPSHOT_YET;
|
||||
}
|
||||
return snapshot.get().ageAt(now).compareTo(maxStaleness) > 0
|
||||
? Decision.STALE_BEYOND_BOUND
|
||||
: Decision.SERVE_LAST_KNOWN_GOOD;
|
||||
}
|
||||
|
||||
/** What the client does about a control-plane outage. */
|
||||
public enum Decision {
|
||||
/** Nothing has arrived yet; wait until the initial fetch timeout, then fail. */
|
||||
NO_SNAPSHOT_YET,
|
||||
/** Keep routing on the snapshot in hand. */
|
||||
SERVE_LAST_KNOWN_GOOD,
|
||||
/** The snapshot is older than the bound; stop trusting it. */
|
||||
STALE_BEYOND_BOUND
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.caskeleton.grpc.advanced.xds;
|
||||
|
||||
import dev.caskeleton.grpc.resilience.GrpcRetryOwner;
|
||||
import java.net.URI;
|
||||
|
||||
/**
|
||||
* A proxyless xDS deployment's configuration.
|
||||
*
|
||||
* <p>xDS moves routing, load balancing, retries and often mTLS out of the application and into a
|
||||
* control plane. The consequence this profile encodes is that the application must stop configuring
|
||||
* them: retry policy defined in both places is defined twice, and which one wins depends on
|
||||
* resolution order rather than on anyone's decision.
|
||||
*/
|
||||
public record GrpcXdsProfile(
|
||||
URI target,
|
||||
String bootstrapReference,
|
||||
String resourceNamespace,
|
||||
GrpcRetryOwner retryOwner,
|
||||
boolean controlPlaneMutualTls) {
|
||||
|
||||
/** The only scheme an xDS target may use. */
|
||||
public static final String XDS_SCHEME = "xds";
|
||||
|
||||
/** Refuses a profile that would leave retries or routing owned in two places. */
|
||||
public GrpcXdsProfile {
|
||||
if (target == null || !XDS_SCHEME.equals(target.getScheme())) {
|
||||
throw new IllegalArgumentException(
|
||||
"an xDS profile needs an 'xds:///' target; got '" + target + "'");
|
||||
}
|
||||
if (bootstrapReference == null || bootstrapReference.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"xDS needs a bootstrap reference; without one the client has no control plane to ask");
|
||||
}
|
||||
if (resourceNamespace == null || resourceNamespace.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"an xDS profile names its resource namespace; a client that subscribes to everything "
|
||||
+ "receives another team's routing");
|
||||
}
|
||||
if (retryOwner != GrpcRetryOwner.SERVICE_MESH) {
|
||||
throw new IllegalArgumentException(
|
||||
"xDS routing means the control plane owns retries; a retry owner of "
|
||||
+ retryOwner
|
||||
+ " would define retry policy in two places, and which wins depends on resolution "
|
||||
+ "order rather than on a decision");
|
||||
}
|
||||
if (!controlPlaneMutualTls) {
|
||||
throw new IllegalArgumentException(
|
||||
"the control-plane connection carries routing and often certificates; it is authenticated "
|
||||
+ "in both directions or it is a channel that can be impersonated");
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package dev.caskeleton.grpc.advanced.xds;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What the control plane last said, and when.
|
||||
*
|
||||
* <p>The timestamp is what makes last-known-good usable. A snapshot with no age cannot answer
|
||||
* whether the control plane has been silent for a minute or a day, and those are a transient blip
|
||||
* and a serious incident.
|
||||
*/
|
||||
public record GrpcXdsResourceSnapshot(
|
||||
String versionInfo, List<String> resourceNames, Instant receivedAt) {
|
||||
|
||||
/** Requires a version, at least one resource and a receipt time. */
|
||||
public GrpcXdsResourceSnapshot {
|
||||
if (versionInfo == null || versionInfo.isBlank()) {
|
||||
throw new IllegalArgumentException("an xDS snapshot carries the control plane's version");
|
||||
}
|
||||
if (resourceNames == null || resourceNames.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"an empty resource set is not a snapshot; a control plane that returns nothing has not "
|
||||
+ "told the client its routing was removed");
|
||||
}
|
||||
if (receivedAt == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"a snapshot records when it arrived; without it, last-known-good cannot say how old it is");
|
||||
}
|
||||
resourceNames = List.copyOf(resourceNames);
|
||||
}
|
||||
|
||||
/** How old this snapshot is at {@code now}. */
|
||||
public java.time.Duration ageAt(Instant now) {
|
||||
if (now == null) {
|
||||
throw new IllegalArgumentException("an age needs a moment");
|
||||
}
|
||||
return java.time.Duration.between(receivedAt, now);
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package dev.caskeleton.grpc.advanced.xds;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedFeatureFlags;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedModuleGuard;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Refuses to start an xDS channel that is not fully configured, and refuses to let xDS be described
|
||||
* as Stable support.
|
||||
*
|
||||
* <p>The second refusal is the one worth having in code. xDS working in a deployment is not the
|
||||
* same claim as the platform supporting it: it brings a control plane, its outage modes, its own
|
||||
* security boundary and its own version skew, and the Stable support statement covers DNS and
|
||||
* static targets. A support matrix that quietly widens is a support matrix nobody can rely on.
|
||||
*/
|
||||
public final class GrpcXdsStartupGuard {
|
||||
|
||||
private GrpcXdsStartupGuard() {}
|
||||
|
||||
/**
|
||||
* Every reason an xDS channel may not start.
|
||||
*
|
||||
* @return an empty list when the profile and flags permit it
|
||||
*/
|
||||
public static List<String> startupBlockers(
|
||||
GrpcXdsProfile profile, GrpcAdvancedFeatureFlags flags, boolean applicationDefinesRetries) {
|
||||
if (profile == null || flags == null) {
|
||||
throw new IllegalArgumentException("startup validation needs a profile and the flags");
|
||||
}
|
||||
List<String> blockers = new ArrayList<>();
|
||||
if (!GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.XDS)) {
|
||||
blockers.add(
|
||||
"the xds capability is not available; it is "
|
||||
+ flags.gradeOf(GrpcAdvancedCapability.XDS)
|
||||
+ " and "
|
||||
+ (flags.flagSet(GrpcAdvancedCapability.XDS)
|
||||
? "production has not approved it"
|
||||
: "its flag is not set"));
|
||||
}
|
||||
if (applicationDefinesRetries) {
|
||||
blockers.add(
|
||||
"the application also defines retry policy; with xDS the control plane owns it, and "
|
||||
+ "defining it in both places makes the winner depend on resolution order");
|
||||
}
|
||||
return List.copyOf(blockers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every disagreement between a deployment's profile and the bootstrap file its client will read.
|
||||
*
|
||||
* <p>Checked because the two are written by different people in different repositories, and the
|
||||
* failure is silent: a client whose bootstrap names a namespace the deployment did not configure
|
||||
* subscribes successfully and receives another team's routing. Nothing errors — the control plane
|
||||
* answers, the resources parse, and traffic goes somewhere nobody chose.
|
||||
*
|
||||
* <p>Matched textually rather than with a JSON parser, deliberately. This leaf's test classpath
|
||||
* is plain JUnit and AssertJ, and adding a JSON library to check three fields would put a parser
|
||||
* on the runtime classpath of every deployment that enables xDS.
|
||||
*
|
||||
* @param bootstrapJson the bootstrap document's contents
|
||||
* @return an empty list when the bootstrap and the profile agree
|
||||
*/
|
||||
public static List<String> bootstrapMismatches(GrpcXdsProfile profile, String bootstrapJson) {
|
||||
if (profile == null) {
|
||||
throw new IllegalArgumentException("a profile is required");
|
||||
}
|
||||
if (bootstrapJson == null || bootstrapJson.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"the bootstrap document is required; a client with no bootstrap has no control plane to ask");
|
||||
}
|
||||
List<String> mismatches = new ArrayList<>();
|
||||
if (!bootstrapJson.contains("\"xds_servers\"")) {
|
||||
mismatches.add("the bootstrap declares no xds_servers");
|
||||
}
|
||||
if (!bootstrapJson.contains("\"channel_creds\"") || !bootstrapJson.contains("\"tls\"")) {
|
||||
mismatches.add(
|
||||
"the bootstrap's control-plane channel is not TLS; that connection carries routing and "
|
||||
+ "often certificates, so an unauthenticated one can be impersonated");
|
||||
}
|
||||
if (!bootstrapJson.contains(profile.resourceNamespace())) {
|
||||
mismatches.add(
|
||||
"the bootstrap does not name the profile's resource namespace '"
|
||||
+ profile.resourceNamespace()
|
||||
+ "'; a client that subscribes outside its namespace receives another team's routing, "
|
||||
+ "and nothing about that fails");
|
||||
}
|
||||
return List.copyOf(mismatches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether xDS may be advertised as part of Stable discovery support.
|
||||
*
|
||||
* <p>Always false. Stable support is DNS and static.
|
||||
*/
|
||||
public static boolean advertisableAsStableSupport() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package dev.caskeleton.grpc.advanced.resilience;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.core.GrpcMethodName;
|
||||
import dev.caskeleton.grpc.core.RpcType;
|
||||
import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile;
|
||||
import dev.caskeleton.grpc.policy.GrpcMethodPolicy;
|
||||
import dev.caskeleton.grpc.policy.RpcIdempotencyProfile;
|
||||
import dev.caskeleton.grpc.policy.WaitForReadyPolicy;
|
||||
import dev.caskeleton.grpc.resilience.GrpcRetryOwner;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcHedgingEligibilityTest {
|
||||
|
||||
private static final GrpcMethodName GET =
|
||||
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
|
||||
private static final GrpcMethodName CREATE =
|
||||
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument");
|
||||
private static final GrpcMethodName WATCH =
|
||||
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/WatchDocuments");
|
||||
private static final GrpcDeadlineProfile TWO_SECONDS =
|
||||
GrpcDeadlineProfile.of(Duration.ofSeconds(2));
|
||||
|
||||
@Test
|
||||
@DisplayName("only a read-only unary method may be hedged")
|
||||
void onlyReadOnlyUnaryMayBeHedged() {
|
||||
assertThat(
|
||||
GrpcHedgingEligibility.eligible(
|
||||
GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS), GrpcRetryOwner.GRPC_PLATFORM))
|
||||
.isTrue();
|
||||
assertThat(
|
||||
GrpcHedgingEligibility.refusalReason(
|
||||
GrpcMethodPolicy.nonIdempotentUnary(CREATE, TWO_SECONDS),
|
||||
GrpcRetryOwner.GRPC_PLATFORM))
|
||||
.hasValueSatisfying(
|
||||
reason -> assertThat(reason).contains("duplicates a success in progress"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a keyed mutation is still refused; an idempotency key does not make hedging safe")
|
||||
void aKeyedMutationIsStillRefused() {
|
||||
GrpcMethodPolicy keyed =
|
||||
new GrpcMethodPolicy(
|
||||
CREATE,
|
||||
RpcType.UNARY,
|
||||
RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED,
|
||||
TWO_SECONDS,
|
||||
WaitForReadyPolicy.DISABLED,
|
||||
false,
|
||||
1024,
|
||||
1024);
|
||||
|
||||
assertThat(GrpcHedgingEligibility.eligible(keyed, GrpcRetryOwner.GRPC_PLATFORM)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a streaming method may not be hedged")
|
||||
void streamingMayNotBeHedged() {
|
||||
GrpcMethodPolicy streaming =
|
||||
new GrpcMethodPolicy(
|
||||
WATCH,
|
||||
RpcType.SERVER_STREAMING,
|
||||
RpcIdempotencyProfile.READ_ONLY,
|
||||
TWO_SECONDS,
|
||||
WaitForReadyPolicy.DISABLED,
|
||||
false,
|
||||
1024,
|
||||
1024);
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> GrpcHedgingEligibility.require(streaming, GrpcRetryOwner.GRPC_PLATFORM))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("two prefixes");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a mesh-owned channel may not hedge in-process")
|
||||
void aMeshOwnedChannelMayNotHedge() {
|
||||
assertThat(
|
||||
GrpcHedgingEligibility.eligible(
|
||||
GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS), GrpcRetryOwner.SERVICE_MESH))
|
||||
.isFalse();
|
||||
assertThat(
|
||||
GrpcHedgingEligibility.eligible(
|
||||
GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS), GrpcRetryOwner.APPLICATION))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hedging is capped at two attempts and needs a meaningful delay")
|
||||
void hedgingIsCappedAndDelayed() {
|
||||
assertThat(
|
||||
GrpcHedgingPolicy.hedgeOnce(Duration.ofMillis(50), Duration.ofSeconds(2)).maxAttempts())
|
||||
.isEqualTo(2);
|
||||
assertThatThrownBy(() -> new GrpcHedgingPolicy(3, Duration.ofMillis(50), Duration.ofSeconds(2)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("capped at 2");
|
||||
assertThatThrownBy(() -> new GrpcHedgingPolicy(2, Duration.ZERO, Duration.ofSeconds(2)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("doubles load for every call");
|
||||
assertThatThrownBy(() -> new GrpcHedgingPolicy(2, Duration.ofSeconds(3), Duration.ofSeconds(2)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("never starts");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the hedging budget bounds duplicate load and refuses an absurd ratio")
|
||||
void theHedgingBudgetBoundsDuplicateLoad() {
|
||||
// ratio 0.5 costs two tokens per hedge, so a four-token budget affords two hedges and then
|
||||
// needs two completions before it can afford another.
|
||||
GrpcHedgingBudget budget = GrpcHedgingBudget.of(0.5d, 4L);
|
||||
|
||||
assertThat(budget.tryConsume()).isTrue();
|
||||
assertThat(budget.tryConsume()).isTrue();
|
||||
assertThat(budget.tryConsume()).isFalse();
|
||||
assertThat(budget.availableTokens()).isZero();
|
||||
|
||||
budget.recordCompletion();
|
||||
assertThat(budget.exhausted()).isTrue();
|
||||
budget.recordCompletion();
|
||||
assertThat(budget.exhausted()).isFalse();
|
||||
|
||||
assertThatThrownBy(() -> GrpcHedgingBudget.of(0.9d, 10L))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("load decision rather than a latency one");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a hedged result records both the saving and the duplicate load")
|
||||
void aHedgedResultRecordsBothSides() {
|
||||
GrpcHedgingResult hedged = new GrpcHedgingResult(2, 2, 1, 1, Duration.ofMillis(40));
|
||||
|
||||
assertThat(hedged.hedged()).isTrue();
|
||||
assertThat(hedged.duplicateBackendCalls()).isEqualTo(1);
|
||||
assertThat(hedged.cancelledLoserAttempts()).isEqualTo(1);
|
||||
assertThat(GrpcHedgingResult.firstAttemptWon(Duration.ofMillis(10)).hedged()).isFalse();
|
||||
assertThatThrownBy(() -> new GrpcHedgingResult(2, 3, 0, 0, Duration.ZERO))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new GrpcHedgingResult(2, 1, 2, 0, Duration.ZERO))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package dev.caskeleton.grpc.advanced.resilience;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.discovery.GrpcEndpointCandidate;
|
||||
import dev.caskeleton.grpc.advanced.discovery.GrpcLoadBalancerDecision;
|
||||
import dev.caskeleton.grpc.advanced.discovery.GrpcLoadBalancerPicker;
|
||||
import dev.caskeleton.grpc.advanced.discovery.GrpcLoadBalancerSafetyPolicy;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcLoadBalancerSafetyPolicyTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("a picker may only choose an endpoint the resolver supplied")
|
||||
void aPickerMayNotInventAnEndpoint() {
|
||||
GrpcLoadBalancerSafetyPolicy policy =
|
||||
new GrpcLoadBalancerSafetyPolicy(candidates -> GrpcEndpointCandidate.ready("10.9.9.9"));
|
||||
|
||||
GrpcLoadBalancerDecision decision =
|
||||
policy.pick(List.of(GrpcEndpointCandidate.ready("10.0.0.1")));
|
||||
|
||||
assertThat(decision.verdict())
|
||||
.isEqualTo(GrpcLoadBalancerDecision.Verdict.DETERMINISTIC_FALLBACK);
|
||||
assertThat(decision.reason()).contains("can send a request anywhere");
|
||||
assertThat(decision.chosen())
|
||||
.hasValueSatisfying(endpoint -> assertThat(endpoint.address()).isEqualTo("10.0.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a picker that throws degrades balancing, not availability")
|
||||
void aThrowingPickerFallsBack() {
|
||||
GrpcLoadBalancerSafetyPolicy policy =
|
||||
new GrpcLoadBalancerSafetyPolicy(
|
||||
candidates -> {
|
||||
throw new IllegalStateException("picker bug");
|
||||
});
|
||||
|
||||
GrpcLoadBalancerDecision decision =
|
||||
policy.pick(List.of(GrpcEndpointCandidate.ready("10.0.0.1")));
|
||||
|
||||
assertThat(decision.verdict())
|
||||
.isEqualTo(GrpcLoadBalancerDecision.Verdict.DETERMINISTIC_FALLBACK);
|
||||
assertThat(decision.reason()).contains("IllegalStateException");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a picker returning null falls back rather than failing the call")
|
||||
void aNullPickFallsBack() {
|
||||
GrpcLoadBalancerSafetyPolicy policy = new GrpcLoadBalancerSafetyPolicy(candidates -> null);
|
||||
|
||||
assertThat(policy.pick(List.of(GrpcEndpointCandidate.ready("10.0.0.1"))).verdict())
|
||||
.isEqualTo(GrpcLoadBalancerDecision.Verdict.DETERMINISTIC_FALLBACK);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no selectable endpoint is a different verdict from a picker failure")
|
||||
void noEndpointDiffersFromAPickerFailure() {
|
||||
GrpcLoadBalancerSafetyPolicy policy =
|
||||
new GrpcLoadBalancerSafetyPolicy(GrpcLoadBalancerPicker.roundRobin());
|
||||
|
||||
assertThat(
|
||||
policy
|
||||
.pick(List.of(new GrpcEndpointCandidate("10.0.0.1", false, true, 100, false)))
|
||||
.verdict())
|
||||
.isEqualTo(GrpcLoadBalancerDecision.Verdict.NO_ENDPOINT_AVAILABLE);
|
||||
assertThat(
|
||||
policy
|
||||
.pick(List.of(new GrpcEndpointCandidate("10.0.0.1", true, true, 100, true)))
|
||||
.verdict())
|
||||
.isEqualTo(GrpcLoadBalancerDecision.Verdict.NO_ENDPOINT_AVAILABLE);
|
||||
assertThat(
|
||||
policy
|
||||
.pick(List.of(new GrpcEndpointCandidate("10.0.0.1", true, true, 0, false)))
|
||||
.verdict())
|
||||
.isEqualTo(GrpcLoadBalancerDecision.Verdict.NO_ENDPOINT_AVAILABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a healthy endpoint is picked and reported as the picker's own choice")
|
||||
void aHealthyEndpointIsPicked() {
|
||||
GrpcLoadBalancerSafetyPolicy policy =
|
||||
new GrpcLoadBalancerSafetyPolicy(GrpcLoadBalancerPicker.roundRobin());
|
||||
|
||||
assertThat(
|
||||
policy
|
||||
.pick(
|
||||
List.of(
|
||||
GrpcEndpointCandidate.ready("10.0.0.1"),
|
||||
GrpcEndpointCandidate.ready("10.0.0.2")))
|
||||
.verdict())
|
||||
.isEqualTo(GrpcLoadBalancerDecision.Verdict.PICKED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a picker sees endpoints only, never a request or a caller")
|
||||
void aPickerSeesEndpointsOnly() {
|
||||
assertThat(GrpcLoadBalancerPicker.class.getMethods())
|
||||
.filteredOn(method -> "pick".equals(method.getName()))
|
||||
.singleElement()
|
||||
.satisfies(method -> assertThat(method.getParameterCount()).isEqualTo(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a candidate carries only routing-relevant state, bounded")
|
||||
void aCandidateCarriesOnlyRoutingState() {
|
||||
assertThat(GrpcEndpointCandidate.ready("10.0.0.1").selectable()).isTrue();
|
||||
assertThatThrownBy(() -> new GrpcEndpointCandidate("10.0.0.1", true, true, 5000, false))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("scale nobody can reason about");
|
||||
assertThatThrownBy(() -> new GrpcEndpointCandidate(" ", true, true, 100, false))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a load-aware or weighted picker requires evidence before it ships")
|
||||
void loadAwarePickersRequireEvidence() {
|
||||
assertThat(GrpcLoadBalancerSafetyPolicy.requiresLoadEvidence(true, false)).isTrue();
|
||||
assertThat(GrpcLoadBalancerSafetyPolicy.requiresLoadEvidence(false, true)).isTrue();
|
||||
assertThat(GrpcLoadBalancerSafetyPolicy.requiresLoadEvidence(false, false)).isFalse();
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package dev.caskeleton.grpc.advanced.resilience;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.discovery.GrpcCustomResolver;
|
||||
import dev.caskeleton.grpc.advanced.discovery.GrpcEndpointSnapshot;
|
||||
import dev.caskeleton.grpc.advanced.discovery.GrpcResolverSafetyPolicy;
|
||||
import dev.caskeleton.grpc.advanced.discovery.GrpcResolverUpdate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcResolverSafetyPolicyTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("an empty or duplicate endpoint set is refused")
|
||||
void anEmptyOrDuplicateEndpointSetIsRefused() {
|
||||
assertThatThrownBy(() -> new GrpcEndpointSnapshot(1L, "documents", List.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("take the channel down with it");
|
||||
assertThatThrownBy(
|
||||
() -> new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1", "10.0.0.1")))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("skew a round-robin picker");
|
||||
assertThatThrownBy(() -> new GrpcEndpointSnapshot(0L, "documents", List.of("10.0.0.1")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a stale revision is dropped rather than applied")
|
||||
void aStaleRevisionIsDropped() {
|
||||
List<GrpcResolverUpdate> delivered = new ArrayList<>();
|
||||
try (GrpcCustomResolver resolver = new GrpcCustomResolver("documents", delivered::add)) {
|
||||
assertThat(
|
||||
resolver.offer(
|
||||
GrpcResolverUpdate.endpointsOnly(
|
||||
new GrpcEndpointSnapshot(2L, "documents", List.of("10.0.0.1")))))
|
||||
.isEmpty();
|
||||
assertThat(
|
||||
resolver.offer(
|
||||
GrpcResolverUpdate.endpointsOnly(
|
||||
new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.9")))))
|
||||
.anySatisfy(violation -> assertThat(violation).contains("older ones"));
|
||||
assertThat(delivered).hasSize(1);
|
||||
assertThat(resolver.currentSnapshot())
|
||||
.hasValueSatisfying(snapshot -> assertThat(snapshot.revision()).isEqualTo(2L));
|
||||
assertThat(resolver.authority()).isEqualTo("documents");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a resolver accepts nothing after close")
|
||||
void aClosedResolverAcceptsNothing() {
|
||||
List<GrpcResolverUpdate> delivered = new ArrayList<>();
|
||||
GrpcCustomResolver resolver = new GrpcCustomResolver("documents", delivered::add);
|
||||
resolver.close();
|
||||
|
||||
assertThat(resolver.closed()).isTrue();
|
||||
assertThat(
|
||||
resolver.offer(
|
||||
GrpcResolverUpdate.endpointsOnly(
|
||||
new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1")))))
|
||||
.anySatisfy(violation -> assertThat(violation).contains("resolver is closed"));
|
||||
assertThat(delivered).isEmpty();
|
||||
assertThat(GrpcResolverSafetyPolicy.acceptAfterClose()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a resolver may not push credentials or business metadata")
|
||||
void aResolverMayNotPushCredentials() {
|
||||
GrpcResolverUpdate withCredential =
|
||||
new GrpcResolverUpdate(
|
||||
new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1")),
|
||||
Optional.of("{\"authorization\":\"Bearer abc\"}"));
|
||||
|
||||
assertThat(GrpcResolverSafetyPolicy.violations(withCredential, null))
|
||||
.anySatisfy(violation -> assertThat(violation).contains("never authentication material"));
|
||||
assertThat(GrpcResolverSafetyPolicy.mayCarryBusinessMetadata()).isFalse();
|
||||
assertThat(GrpcResolverSafetyPolicy.acceptedServiceConfig(withCredential, null)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a clean service config survives validation")
|
||||
void aCleanServiceConfigSurvives() {
|
||||
GrpcResolverUpdate clean =
|
||||
new GrpcResolverUpdate(
|
||||
new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1")),
|
||||
Optional.of("{\"loadBalancingConfig\":[{\"round_robin\":{}}]}"));
|
||||
|
||||
assertThat(GrpcResolverSafetyPolicy.violations(clean, null)).isEmpty();
|
||||
assertThat(GrpcResolverSafetyPolicy.acceptedServiceConfig(clean, null)).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an invalid authority is refused, because it decides which certificate is accepted")
|
||||
void anInvalidAuthorityIsRefused() {
|
||||
assertThat(
|
||||
GrpcResolverSafetyPolicy.violations(
|
||||
GrpcResolverUpdate.endpointsOnly(
|
||||
new GrpcEndpointSnapshot(1L, "Documents Service", List.of("10.0.0.1"))),
|
||||
null))
|
||||
.anySatisfy(violation -> assertThat(violation).contains("which certificate"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a present service config may not be blank")
|
||||
void aPresentServiceConfigMayNotBeBlank() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcResolverUpdate(
|
||||
new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1")),
|
||||
Optional.of(" ")))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("different states");
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package dev.caskeleton.grpc.advanced.resilience;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedFeatureFlags;
|
||||
import dev.caskeleton.grpc.advanced.xds.GrpcXdsFailurePolicy;
|
||||
import dev.caskeleton.grpc.advanced.xds.GrpcXdsProfile;
|
||||
import dev.caskeleton.grpc.advanced.xds.GrpcXdsResourceSnapshot;
|
||||
import dev.caskeleton.grpc.advanced.xds.GrpcXdsStartupGuard;
|
||||
import dev.caskeleton.grpc.resilience.GrpcRetryOwner;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcXdsStartupGuardTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
|
||||
|
||||
private static GrpcXdsProfile profile(String namespace) {
|
||||
return new GrpcXdsProfile(
|
||||
URI.create("xds:///documents"),
|
||||
"classpath:/xds/bootstrap.json",
|
||||
namespace,
|
||||
GrpcRetryOwner.SERVICE_MESH,
|
||||
true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an xDS profile requires an xds target, a namespace, mesh retries and mTLS")
|
||||
void anXdsProfileRequiresItsFourConditions() {
|
||||
assertThat(profile("hyeonworks/documents").target().getScheme()).isEqualTo("xds");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcXdsProfile(
|
||||
URI.create("dns:///documents"),
|
||||
"file:/etc/grpc/bootstrap.json",
|
||||
"ns",
|
||||
GrpcRetryOwner.SERVICE_MESH,
|
||||
true))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcXdsProfile(
|
||||
URI.create("xds:///documents"), " ", "ns", GrpcRetryOwner.SERVICE_MESH, true))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("no control plane to ask");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcXdsProfile(
|
||||
URI.create("xds:///documents"),
|
||||
"file:/etc/grpc/bootstrap.json",
|
||||
"ns",
|
||||
GrpcRetryOwner.APPLICATION,
|
||||
true))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("two places");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcXdsProfile(
|
||||
URI.create("xds:///documents"),
|
||||
"file:/etc/grpc/bootstrap.json",
|
||||
"ns",
|
||||
GrpcRetryOwner.SERVICE_MESH,
|
||||
false))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("impersonated");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("xDS needs its capability approved and refuses duplicate retry ownership")
|
||||
void startupRequiresApprovalAndSingleRetryOwner() {
|
||||
GrpcAdvancedFeatureFlags unapproved =
|
||||
GrpcAdvancedFeatureFlags.forProduction(Set.of()).enable(GrpcAdvancedCapability.XDS);
|
||||
GrpcAdvancedFeatureFlags approved =
|
||||
GrpcAdvancedFeatureFlags.forProduction(Set.of(GrpcAdvancedCapability.XDS))
|
||||
.enable(GrpcAdvancedCapability.XDS);
|
||||
|
||||
assertThat(
|
||||
GrpcXdsStartupGuard.startupBlockers(profile("hyeonworks/documents"), unapproved, false))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("production has not approved it"));
|
||||
assertThat(
|
||||
GrpcXdsStartupGuard.startupBlockers(profile("hyeonworks/documents"), approved, false))
|
||||
.isEmpty();
|
||||
assertThat(GrpcXdsStartupGuard.startupBlockers(profile("hyeonworks/documents"), approved, true))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("resolution order"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unflagged capability is reported as unflagged rather than unapproved")
|
||||
void anUnflaggedCapabilityIsReportedAsSuch() {
|
||||
assertThat(
|
||||
GrpcXdsStartupGuard.startupBlockers(
|
||||
profile("hyeonworks/documents"),
|
||||
GrpcAdvancedFeatureFlags.forProduction(Set.of()),
|
||||
false))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("its flag is not set"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the committed bootstrap fixture agrees with the profile it is meant to serve")
|
||||
void theBootstrapFixtureAgreesWithItsProfile() {
|
||||
assertThat(
|
||||
GrpcXdsStartupGuard.bootstrapMismatches(
|
||||
profile("hyeonworks/documents"), resource("xds/bootstrap.json")))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a bootstrap naming another namespace is reported, since nothing else would fail")
|
||||
void aNamespaceMismatchIsReported() {
|
||||
assertThat(
|
||||
GrpcXdsStartupGuard.bootstrapMismatches(
|
||||
profile("hyeonworks/billing"), resource("xds/bootstrap.json")))
|
||||
.anySatisfy(mismatch -> assertThat(mismatch).contains("another team's routing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a bootstrap with an unauthenticated control-plane channel is reported")
|
||||
void anUnauthenticatedControlPlaneIsReported() {
|
||||
String insecure =
|
||||
resource("xds/bootstrap.json")
|
||||
.replace("{ \"type\": \"tls\" }", "{ \"type\": \"insecure\" }");
|
||||
|
||||
assertThat(GrpcXdsStartupGuard.bootstrapMismatches(profile("hyeonworks/documents"), insecure))
|
||||
.anySatisfy(mismatch -> assertThat(mismatch).contains("can be impersonated"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a missing bootstrap is refused rather than treated as an empty one")
|
||||
void aMissingBootstrapIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() -> GrpcXdsStartupGuard.bootstrapMismatches(profile("hyeonworks/documents"), " "))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("no control plane to ask");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("last-known-good is bounded, and an empty resource set is not a snapshot")
|
||||
void lastKnownGoodIsBounded() {
|
||||
GrpcXdsFailurePolicy policy = GrpcXdsFailurePolicy.standard();
|
||||
GrpcXdsResourceSnapshot snapshot =
|
||||
new GrpcXdsResourceSnapshot("v7", List.of("documents-cluster"), NOW);
|
||||
|
||||
assertThat(policy.decide(Optional.of(snapshot), NOW.plusSeconds(60)))
|
||||
.isEqualTo(GrpcXdsFailurePolicy.Decision.SERVE_LAST_KNOWN_GOOD);
|
||||
assertThat(policy.decide(Optional.of(snapshot), NOW.plusSeconds(1000)))
|
||||
.isEqualTo(GrpcXdsFailurePolicy.Decision.STALE_BEYOND_BOUND);
|
||||
assertThat(policy.decide(Optional.empty(), NOW))
|
||||
.isEqualTo(GrpcXdsFailurePolicy.Decision.NO_SNAPSHOT_YET);
|
||||
assertThat(snapshot.ageAt(NOW.plusSeconds(60))).isEqualTo(java.time.Duration.ofSeconds(60));
|
||||
assertThatThrownBy(() -> new GrpcXdsResourceSnapshot("v7", List.of(), NOW))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcXdsFailurePolicy(
|
||||
java.time.Duration.ZERO, true, java.time.Duration.ofSeconds(1)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("receiving traffic indefinitely");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("xDS is not part of the Stable discovery support statement")
|
||||
void xdsIsNotStableSupport() {
|
||||
assertThat(GrpcXdsStartupGuard.advertisableAsStableSupport()).isFalse();
|
||||
}
|
||||
|
||||
private static String resource(String path) {
|
||||
try (InputStream stream =
|
||||
GrpcXdsStartupGuardTest.class.getClassLoader().getResourceAsStream(path)) {
|
||||
if (stream == null) {
|
||||
throw new IllegalStateException("missing test resource " + path);
|
||||
}
|
||||
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"_comment": [
|
||||
"An xDS bootstrap fixture. The client reads this file to learn where its control plane is and",
|
||||
"who it claims to be; GrpcXdsProfile validates the deployment settings that must agree with it.",
|
||||
"The two fields the profile actually checks against are server_uri (the control plane must be",
|
||||
"reached over authenticated mTLS) and the node id's namespace (a client that subscribes outside",
|
||||
"its namespace receives another team's routing)."
|
||||
],
|
||||
"xds_servers": [
|
||||
{
|
||||
"server_uri": "xds-control-plane.hyeonworks.internal:15010",
|
||||
"channel_creds": [
|
||||
{ "type": "tls" }
|
||||
],
|
||||
"server_features": ["xds_v3"]
|
||||
}
|
||||
],
|
||||
"node": {
|
||||
"id": "hyeonworks/documents/documents-7f9c4",
|
||||
"cluster": "documents",
|
||||
"metadata": {
|
||||
"NAMESPACE": "hyeonworks/documents"
|
||||
},
|
||||
"locality": {
|
||||
"region": "ap-northeast-2",
|
||||
"zone": "ap-northeast-2a"
|
||||
}
|
||||
},
|
||||
"authorities": {
|
||||
"hyeonworks.internal": {
|
||||
"xds_servers": [
|
||||
{
|
||||
"server_uri": "xds-control-plane.hyeonworks.internal:15010",
|
||||
"channel_creds": [{ "type": "tls" }],
|
||||
"server_features": ["xds_v3"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
// The streaming shapes the Stable plan deliberately excludes: client streaming sessions with
|
||||
// dedup/checkpoint/resume, bidirectional sessions with independent per-direction sequences, and the
|
||||
// manual flow-control approval API.
|
||||
dependencies {
|
||||
api project(':grpc:grpc-core-api')
|
||||
api project(':grpc:grpc-policy')
|
||||
api project(':grpc-advanced:grpc-advanced-bootstrap')
|
||||
}
|
||||
@@ -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=
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package dev.caskeleton.grpc.advanced.streaming;
|
||||
|
||||
/**
|
||||
* One direction of a bidirectional stream, tracked on its own.
|
||||
*
|
||||
* <p>Independently, because the two directions genuinely end at different times: a client that has
|
||||
* finished sending is still receiving, and a server that has finished sending is still reading. A
|
||||
* single state for both cannot express either, and the usual symptom is a stream closed while one
|
||||
* side still had messages to deliver.
|
||||
*/
|
||||
public record GrpcBidiDirectionState(long lastSequence, boolean halfClosed, boolean cancelled) {
|
||||
|
||||
/** Requires a non-negative position. */
|
||||
public GrpcBidiDirectionState {
|
||||
if (lastSequence < 0) {
|
||||
throw new IllegalArgumentException("a direction's sequence is at or after zero");
|
||||
}
|
||||
if (halfClosed && cancelled) {
|
||||
throw new IllegalArgumentException(
|
||||
"a direction that was cancelled did not half-close; the two are different endings and a "
|
||||
+ "client acts on them differently");
|
||||
}
|
||||
}
|
||||
|
||||
/** A direction that has sent nothing yet. */
|
||||
public static GrpcBidiDirectionState open() {
|
||||
return new GrpcBidiDirectionState(0L, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a message at the next sequence.
|
||||
*
|
||||
* @throws IllegalStateException when the direction has already ended
|
||||
*/
|
||||
public GrpcBidiDirectionState advanced() {
|
||||
if (halfClosed || cancelled) {
|
||||
throw new IllegalStateException(
|
||||
"this direction has already ended; a message after half-close is a protocol error, not a "
|
||||
+ "late arrival");
|
||||
}
|
||||
return new GrpcBidiDirectionState(lastSequence + 1, false, false);
|
||||
}
|
||||
|
||||
/** Records that this direction will send nothing more. */
|
||||
public GrpcBidiDirectionState halfClose() {
|
||||
if (cancelled) {
|
||||
throw new IllegalStateException("a cancelled direction cannot half-close");
|
||||
}
|
||||
return new GrpcBidiDirectionState(lastSequence, true, false);
|
||||
}
|
||||
|
||||
/** Records that this direction was cancelled. */
|
||||
public GrpcBidiDirectionState cancel() {
|
||||
return new GrpcBidiDirectionState(lastSequence, false, true);
|
||||
}
|
||||
|
||||
/** Whether this direction has finished. */
|
||||
public boolean ended() {
|
||||
return halfClosed || cancelled;
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package dev.caskeleton.grpc.advanced.streaming;
|
||||
|
||||
/**
|
||||
* What a bidirectional resume has to carry: both applied positions, and the generation they belong
|
||||
* to.
|
||||
*
|
||||
* <p>Both, because a resume that restores one direction and restarts the other produces exactly the
|
||||
* failure the dual sequence exists to prevent. The generation is checked first: a mismatch means
|
||||
* the session was restarted rather than resumed, and continuing from either position would be
|
||||
* meaningless.
|
||||
*/
|
||||
public record GrpcBidiResumeState(
|
||||
GrpcClientStreamSessionId sessionId, long clientAppliedSequence, long serverAppliedSequence) {
|
||||
|
||||
/** Requires a session and two non-negative positions. */
|
||||
public GrpcBidiResumeState {
|
||||
if (sessionId == null) {
|
||||
throw new IllegalArgumentException("a bidi resume state belongs to a session");
|
||||
}
|
||||
if (clientAppliedSequence < 0 || serverAppliedSequence < 0) {
|
||||
throw new IllegalArgumentException("applied sequences are at or after zero");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code presented} can continue this session.
|
||||
*
|
||||
* <p>A generation mismatch requires a full restart. The session was re-established rather than
|
||||
* resumed, so neither side's position refers to the same stream of messages.
|
||||
*/
|
||||
public boolean resumableFrom(GrpcClientStreamSessionId presented) {
|
||||
return presented != null
|
||||
&& sessionId.sameSessionAs(presented)
|
||||
&& presented.generation() == sessionId.generation();
|
||||
}
|
||||
|
||||
/** The state after a resume, at the next generation. */
|
||||
public GrpcBidiResumeState resumed() {
|
||||
return new GrpcBidiResumeState(
|
||||
sessionId.resumed(), clientAppliedSequence, serverAppliedSequence);
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package dev.caskeleton.grpc.advanced.streaming;
|
||||
|
||||
/**
|
||||
* Two sequences, one per direction, never merged.
|
||||
*
|
||||
* <p>Merging them is the mistake this class exists to prevent, and it is tempting because one
|
||||
* counter looks simpler. It is not equivalent: the client's message 5 and the server's message 5
|
||||
* are unrelated events, and a shared counter makes a resume token from one side meaningless to the
|
||||
* other — so a reconnect either skips or replays, depending on which side moved faster.
|
||||
*/
|
||||
public final class GrpcBidiSequenceTracker {
|
||||
|
||||
private GrpcBidiDirectionState clientToServer = GrpcBidiDirectionState.open();
|
||||
private GrpcBidiDirectionState serverToClient = GrpcBidiDirectionState.open();
|
||||
|
||||
/** Records a client-to-server message and returns its sequence. */
|
||||
public synchronized long nextClientSequence() {
|
||||
clientToServer = clientToServer.advanced();
|
||||
return clientToServer.lastSequence();
|
||||
}
|
||||
|
||||
/** Records a server-to-client message and returns its sequence. */
|
||||
public synchronized long nextServerSequence() {
|
||||
serverToClient = serverToClient.advanced();
|
||||
return serverToClient.lastSequence();
|
||||
}
|
||||
|
||||
/** The client-to-server direction. */
|
||||
public synchronized GrpcBidiDirectionState clientToServer() {
|
||||
return clientToServer;
|
||||
}
|
||||
|
||||
/** The server-to-client direction. */
|
||||
public synchronized GrpcBidiDirectionState serverToClient() {
|
||||
return serverToClient;
|
||||
}
|
||||
|
||||
/** Records that the client will send nothing more. */
|
||||
public synchronized void halfCloseClient() {
|
||||
clientToServer = clientToServer.halfClose();
|
||||
}
|
||||
|
||||
/** Records that the server will send nothing more. */
|
||||
public synchronized void halfCloseServer() {
|
||||
serverToClient = serverToClient.halfClose();
|
||||
}
|
||||
|
||||
/** Cancels one direction without touching the other. */
|
||||
public synchronized void cancelClient() {
|
||||
clientToServer = clientToServer.cancel();
|
||||
}
|
||||
|
||||
/** Cancels the server direction without touching the client's. */
|
||||
public synchronized void cancelServer() {
|
||||
serverToClient = serverToClient.cancel();
|
||||
}
|
||||
|
||||
/** Whether both directions have ended. */
|
||||
public synchronized boolean bothEnded() {
|
||||
return clientToServer.ended() && serverToClient.ended();
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package dev.caskeleton.grpc.advanced.streaming;
|
||||
|
||||
import dev.caskeleton.grpc.streaming.GrpcFlowControlPolicy;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A bidirectional session: two directions, two queues, two single writers.
|
||||
*
|
||||
* <p>Two of everything, because the two directions have independent producers and independent
|
||||
* consumers. Sharing a queue between them means the slower direction throttles the faster one; and
|
||||
* sharing a writer reintroduces the concurrent-{@code onNext} problem the Stable serialized writer
|
||||
* exists to solve, this time with the two sides of the same call racing each other.
|
||||
*/
|
||||
public final class GrpcBidiSession {
|
||||
|
||||
private final GrpcClientStreamSessionId sessionId;
|
||||
private final GrpcBidiSequenceTracker sequences = new GrpcBidiSequenceTracker();
|
||||
private final GrpcFlowControlPolicy inboundFlowControl;
|
||||
private final GrpcFlowControlPolicy outboundFlowControl;
|
||||
|
||||
/** Opens a session with an independent flow-control policy per direction. */
|
||||
public GrpcBidiSession(
|
||||
GrpcClientStreamSessionId sessionId,
|
||||
GrpcFlowControlPolicy inboundFlowControl,
|
||||
GrpcFlowControlPolicy outboundFlowControl) {
|
||||
if (sessionId == null || inboundFlowControl == null || outboundFlowControl == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"a bidi session needs an id and a flow-control policy for each direction");
|
||||
}
|
||||
this.sessionId = sessionId;
|
||||
this.inboundFlowControl = inboundFlowControl;
|
||||
this.outboundFlowControl = outboundFlowControl;
|
||||
}
|
||||
|
||||
/** The session's identity. */
|
||||
public GrpcClientStreamSessionId sessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/** The sequence tracker. */
|
||||
public GrpcBidiSequenceTracker sequences() {
|
||||
return sequences;
|
||||
}
|
||||
|
||||
/** The inbound direction's bounds. */
|
||||
public GrpcFlowControlPolicy inboundFlowControl() {
|
||||
return inboundFlowControl;
|
||||
}
|
||||
|
||||
/** The outbound direction's bounds. */
|
||||
public GrpcFlowControlPolicy outboundFlowControl() {
|
||||
return outboundFlowControl;
|
||||
}
|
||||
|
||||
/** The resume state as it stands. */
|
||||
public GrpcBidiResumeState resumeState() {
|
||||
return new GrpcBidiResumeState(
|
||||
sessionId,
|
||||
sequences.clientToServer().lastSequence(),
|
||||
sequences.serverToClient().lastSequence());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code presented} may continue this session, and from where.
|
||||
*
|
||||
* @return empty when the generation does not match, which requires a full session restart
|
||||
*/
|
||||
public Optional<GrpcBidiResumeState> resumeFrom(GrpcClientStreamSessionId presented) {
|
||||
GrpcBidiResumeState state = resumeState();
|
||||
return state.resumableFrom(presented) ? Optional.of(state) : Optional.empty();
|
||||
}
|
||||
|
||||
/** Whether the session is finished in both directions. */
|
||||
public boolean complete() {
|
||||
return sequences.bothEnded();
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package dev.caskeleton.grpc.advanced.streaming;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
/**
|
||||
* Decides what to do with a client stream message that may have arrived before.
|
||||
*
|
||||
* <p>Checkpoint-based rather than a set of seen keys. A set grows without bound for the life of a
|
||||
* session and answers "have I seen this" — which is not quite the question. The question is "has
|
||||
* this been applied", and a monotonic applied-sequence answers it in constant space and survives
|
||||
* the process restart that a set does not.
|
||||
*
|
||||
* <p>Replayed outcomes are kept for the small window after the checkpoint, so a duplicate that
|
||||
* arrives before the checkpoint advances gets the original answer rather than being reapplied.
|
||||
*/
|
||||
public final class GrpcClientMessageDeduplicator {
|
||||
|
||||
private final ConcurrentMap<String, GrpcClientStreamCheckpoint> checkpoints =
|
||||
new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, String> replayableOutcomes = new ConcurrentHashMap<>();
|
||||
|
||||
/** What to do with an incoming message. */
|
||||
public enum Disposition {
|
||||
/** Not seen; apply it. */
|
||||
APPLY,
|
||||
/** Already applied; return the stored outcome if there is one, otherwise ignore. */
|
||||
REPLAY,
|
||||
/** Out of order ahead of the checkpoint; the sender skipped something. */
|
||||
GAP
|
||||
}
|
||||
|
||||
/** Starts tracking a session. */
|
||||
public void beginSession(GrpcClientStreamSessionId sessionId, Instant at) {
|
||||
if (sessionId == null || at == null) {
|
||||
throw new IllegalArgumentException("a session needs an id and a moment");
|
||||
}
|
||||
checkpoints.putIfAbsent(sessionId.value(), GrpcClientStreamCheckpoint.empty(sessionId, at));
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do with {@code message}.
|
||||
*
|
||||
* @throws IllegalStateException when the session is not being tracked, which means a message
|
||||
* arrived for a session the server never opened
|
||||
*/
|
||||
public Disposition dispositionOf(GrpcClientStreamMessage<?> message) {
|
||||
if (message == null) {
|
||||
throw new IllegalArgumentException("a message is required");
|
||||
}
|
||||
GrpcClientStreamCheckpoint checkpoint = requireCheckpoint(message.sessionId());
|
||||
if (checkpoint.alreadyApplied(message.sequence())) {
|
||||
return Disposition.REPLAY;
|
||||
}
|
||||
if (message.sequence() > checkpoint.lastAppliedSequence() + 1) {
|
||||
return Disposition.GAP;
|
||||
}
|
||||
return Disposition.APPLY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that a message was applied, together with the outcome a duplicate would receive.
|
||||
*
|
||||
* <p>The application effect and this checkpoint belong in one transaction wherever the datastore
|
||||
* allows it. Committing them separately leaves a window in which the effect is durable and the
|
||||
* checkpoint is not, and a reconnect in that window reapplies the message.
|
||||
*/
|
||||
public void recordApplied(
|
||||
GrpcClientStreamMessage<?> message, String outcomeReference, Instant at) {
|
||||
GrpcClientStreamCheckpoint checkpoint = requireCheckpoint(message.sessionId());
|
||||
checkpoints.put(message.sessionId().value(), checkpoint.advancedTo(message.sequence(), at));
|
||||
if (outcomeReference != null && !outcomeReference.isBlank()) {
|
||||
replayableOutcomes.put(message.dedupKey(), outcomeReference);
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored outcome for a duplicate, when one was recorded. */
|
||||
public Optional<String> replayOutcome(GrpcClientStreamMessage<?> message) {
|
||||
return Optional.ofNullable(replayableOutcomes.get(message.dedupKey()));
|
||||
}
|
||||
|
||||
/** The checkpoint for a session. */
|
||||
public Optional<GrpcClientStreamCheckpoint> checkpointOf(GrpcClientStreamSessionId sessionId) {
|
||||
return Optional.ofNullable(checkpoints.get(sessionId.value()));
|
||||
}
|
||||
|
||||
/** Whether a reconnecting client may continue. */
|
||||
public GrpcClientStreamResumeDecision decideResume(
|
||||
GrpcClientStreamSessionId presented, String presentedCaller, String sessionOwner) {
|
||||
if (presented == null) {
|
||||
throw new IllegalArgumentException("a resume needs a presented session");
|
||||
}
|
||||
if (presentedCaller == null || !presentedCaller.equals(sessionOwner)) {
|
||||
return GrpcClientStreamResumeDecision.reject(
|
||||
"the presented session belongs to a different caller");
|
||||
}
|
||||
GrpcClientStreamCheckpoint checkpoint = checkpoints.get(presented.value());
|
||||
if (checkpoint == null) {
|
||||
return GrpcClientStreamResumeDecision.newSession(
|
||||
"the server holds no checkpoint for this session; resuming would leave its prefix either "
|
||||
+ "lost or applied twice, with nothing to tell which");
|
||||
}
|
||||
return GrpcClientStreamResumeDecision.resume(
|
||||
checkpoint.lastAppliedSequence(), "continuing from the last applied message");
|
||||
}
|
||||
|
||||
/** Forgets a finished session. */
|
||||
public void endSession(GrpcClientStreamSessionId sessionId) {
|
||||
checkpoints.remove(sessionId.value());
|
||||
replayableOutcomes.keySet().removeIf(key -> key.startsWith(sessionId.value() + "|"));
|
||||
}
|
||||
|
||||
private GrpcClientStreamCheckpoint requireCheckpoint(GrpcClientStreamSessionId sessionId) {
|
||||
GrpcClientStreamCheckpoint checkpoint = checkpoints.get(sessionId.value());
|
||||
if (checkpoint == null) {
|
||||
throw new IllegalStateException(
|
||||
"no session is open for '" + sessionId.value() + "'; call beginSession first");
|
||||
}
|
||||
return checkpoint;
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package dev.caskeleton.grpc.advanced.streaming;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* How far a client stream's messages have actually been applied.
|
||||
*
|
||||
* <p>Applied, not received. The distinction is the whole contract: the transport acknowledging a
|
||||
* message means it reached the server's buffer, and a checkpoint means the application committed
|
||||
* its effect. A resume that continues from a transport acknowledgement skips everything that was
|
||||
* received and not yet applied when the connection died.
|
||||
*/
|
||||
public record GrpcClientStreamCheckpoint(
|
||||
GrpcClientStreamSessionId sessionId, long lastAppliedSequence, Instant checkpointedAt) {
|
||||
|
||||
/** Requires a session, a non-negative position and a moment. */
|
||||
public GrpcClientStreamCheckpoint {
|
||||
if (sessionId == null) {
|
||||
throw new IllegalArgumentException("a checkpoint belongs to a session");
|
||||
}
|
||||
if (lastAppliedSequence < 0) {
|
||||
throw new IllegalArgumentException("an applied sequence is at or after zero");
|
||||
}
|
||||
if (checkpointedAt == null) {
|
||||
throw new IllegalArgumentException("a checkpoint records when it was taken");
|
||||
}
|
||||
}
|
||||
|
||||
/** A session that has applied nothing yet. */
|
||||
public static GrpcClientStreamCheckpoint empty(GrpcClientStreamSessionId sessionId, Instant at) {
|
||||
return new GrpcClientStreamCheckpoint(sessionId, 0L, at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the checkpoint.
|
||||
*
|
||||
* @throws IllegalArgumentException when it would go backwards, which means two writers are
|
||||
* checkpointing the same session
|
||||
*/
|
||||
public GrpcClientStreamCheckpoint advancedTo(long appliedSequence, Instant at) {
|
||||
if (appliedSequence < lastAppliedSequence) {
|
||||
throw new IllegalArgumentException(
|
||||
"a checkpoint cannot move backwards from "
|
||||
+ lastAppliedSequence
|
||||
+ " to "
|
||||
+ appliedSequence
|
||||
+ "; two writers are checkpointing one session");
|
||||
}
|
||||
return new GrpcClientStreamCheckpoint(sessionId, appliedSequence, at);
|
||||
}
|
||||
|
||||
/** Whether a message at {@code sequence} has already been applied. */
|
||||
public boolean alreadyApplied(long sequence) {
|
||||
return sequence <= lastAppliedSequence;
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.caskeleton.grpc.advanced.streaming;
|
||||
|
||||
/**
|
||||
* One message a client sends, carrying the session and sequence that make it identifiable.
|
||||
*
|
||||
* <p>The sequence is the client's, not the transport's. gRPC guarantees ordering within a stream
|
||||
* and says nothing about a stream that was re-established; a client that reconnects and resends
|
||||
* needs its own numbering for the server to tell a resend from a new message.
|
||||
*
|
||||
* @param <T> the payload type
|
||||
*/
|
||||
public record GrpcClientStreamMessage<T>(
|
||||
GrpcClientStreamSessionId sessionId, long sequence, T payload) {
|
||||
|
||||
/** Requires a session, a one-based sequence and a payload. */
|
||||
public GrpcClientStreamMessage {
|
||||
if (sessionId == null) {
|
||||
throw new IllegalArgumentException("a client stream message belongs to a session");
|
||||
}
|
||||
if (sequence < 1) {
|
||||
throw new IllegalArgumentException("client stream sequences are 1-based; got " + sequence);
|
||||
}
|
||||
if (payload == null) {
|
||||
throw new IllegalArgumentException("a client stream message carries a payload");
|
||||
}
|
||||
}
|
||||
|
||||
/** The dedup key for this message. */
|
||||
public String dedupKey() {
|
||||
return sessionId.dedupKey(sequence);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.grpc.advanced.streaming;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* The bounds a client stream runs under.
|
||||
*
|
||||
* <p>A client stream is an inbound firehose with a single response at the end, so all four bounds
|
||||
* are about the client rather than the server: how long it may hold the stream, how long it may go
|
||||
* quiet, how fast it may send, and how much it may have unacknowledged. Without them, one client
|
||||
* can occupy a server thread indefinitely while sending nothing.
|
||||
*
|
||||
* <p>Whole-stream transparent retry is not a setting. It is refused, because replaying a stream the
|
||||
* server partially applied applies its prefix twice.
|
||||
*/
|
||||
public record GrpcClientStreamPolicy(
|
||||
Duration maxDuration, Duration idleTimeout, int maxMessagesPerSecond, int maxInFlightMessages) {
|
||||
|
||||
/** Refuses an unbounded client stream. */
|
||||
public GrpcClientStreamPolicy {
|
||||
if (maxDuration == null || maxDuration.isZero() || maxDuration.isNegative()) {
|
||||
throw new IllegalArgumentException("a client stream needs a maximum duration");
|
||||
}
|
||||
if (idleTimeout == null || idleTimeout.isZero() || idleTimeout.isNegative()) {
|
||||
throw new IllegalArgumentException("a client stream needs an idle timeout");
|
||||
}
|
||||
if (idleTimeout.compareTo(maxDuration) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"an idle timeout longer than the max duration never fires");
|
||||
}
|
||||
if (maxMessagesPerSecond < 1 || maxInFlightMessages < 1) {
|
||||
throw new IllegalArgumentException("message rate and in-flight bounds must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
/** A default for an upload-shaped client stream. */
|
||||
public static GrpcClientStreamPolicy standard() {
|
||||
return new GrpcClientStreamPolicy(Duration.ofMinutes(10), Duration.ofSeconds(30), 500, 64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a whole client stream may be transparently retried.
|
||||
*
|
||||
* <p>Always false. A stream whose prefix the server already applied cannot be replayed without
|
||||
* applying that prefix again, and the transport has no way to know how much was applied.
|
||||
*/
|
||||
public boolean wholeStreamRetryAllowed() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package dev.caskeleton.grpc.advanced.streaming;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Whether a reconnecting client may continue its session.
|
||||
*
|
||||
* <p>{@link Verdict#NEW_SESSION_REQUIRED} is what a server says when it no longer holds the
|
||||
* checkpoint. Letting the client resume anyway would mean accepting messages numbered from where it
|
||||
* thinks it stopped, against a server that has no record of the prefix — so the prefix is either
|
||||
* lost or applied twice, and nothing detects which.
|
||||
*/
|
||||
public record GrpcClientStreamResumeDecision(
|
||||
Verdict verdict, Optional<Long> resumeFromSequence, String reason) {
|
||||
|
||||
/** What the server decided. */
|
||||
public enum Verdict {
|
||||
/** Continue from the checkpoint. */
|
||||
RESUME,
|
||||
/** The session is gone; start a new one and resend everything. */
|
||||
NEW_SESSION_REQUIRED,
|
||||
/** The presented session does not belong to this caller. */
|
||||
REJECTED
|
||||
}
|
||||
|
||||
/** Requires a sequence only on RESUME. */
|
||||
public GrpcClientStreamResumeDecision {
|
||||
if (verdict == null || resumeFromSequence == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"a resume decision has a verdict and the sequence Optional");
|
||||
}
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new IllegalArgumentException("a resume decision explains itself");
|
||||
}
|
||||
if (verdict == Verdict.RESUME && resumeFromSequence.isEmpty()) {
|
||||
throw new IllegalArgumentException("a RESUME decision says where to continue from");
|
||||
}
|
||||
if (verdict != Verdict.RESUME && resumeFromSequence.isPresent()) {
|
||||
throw new IllegalArgumentException("only a RESUME decision carries a sequence");
|
||||
}
|
||||
}
|
||||
|
||||
/** Continue from {@code sequence}. */
|
||||
public static GrpcClientStreamResumeDecision resume(long sequence, String reason) {
|
||||
return new GrpcClientStreamResumeDecision(Verdict.RESUME, Optional.of(sequence), reason);
|
||||
}
|
||||
|
||||
/** Start over. */
|
||||
public static GrpcClientStreamResumeDecision newSession(String reason) {
|
||||
return new GrpcClientStreamResumeDecision(
|
||||
Verdict.NEW_SESSION_REQUIRED, Optional.empty(), reason);
|
||||
}
|
||||
|
||||
/** Refuse the presented session. */
|
||||
public static GrpcClientStreamResumeDecision reject(String reason) {
|
||||
return new GrpcClientStreamResumeDecision(Verdict.REJECTED, Optional.empty(), reason);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.caskeleton.grpc.advanced.streaming;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A client stream's identity, plus the generation a reconnect advances.
|
||||
*
|
||||
* <p>The generation is what makes dedup possible across a reconnect. Message 7 of generation 1 and
|
||||
* message 7 of generation 2 are different messages; a dedup key that ignores the generation drops
|
||||
* the second, and the caller's seventh message is silently never applied.
|
||||
*/
|
||||
public record GrpcClientStreamSessionId(String value, long generation) {
|
||||
|
||||
/** Requires a bounded id and a positive generation. */
|
||||
public GrpcClientStreamSessionId {
|
||||
if (value == null || value.isBlank() || value.length() > 64) {
|
||||
throw new IllegalArgumentException("a session id is a bounded non-blank identifier");
|
||||
}
|
||||
if (generation < 1) {
|
||||
throw new IllegalArgumentException("session generations are 1-based; got " + generation);
|
||||
}
|
||||
}
|
||||
|
||||
/** A fresh session. */
|
||||
public static GrpcClientStreamSessionId newSession() {
|
||||
return new GrpcClientStreamSessionId(UUID.randomUUID().toString(), 1L);
|
||||
}
|
||||
|
||||
/** The same session, resumed. */
|
||||
public GrpcClientStreamSessionId resumed() {
|
||||
return new GrpcClientStreamSessionId(value, generation + 1);
|
||||
}
|
||||
|
||||
/** Whether {@code other} is the same logical session in any generation. */
|
||||
public boolean sameSessionAs(GrpcClientStreamSessionId other) {
|
||||
return other != null && value.equals(other.value());
|
||||
}
|
||||
|
||||
/** The dedup key for a message at {@code sequence} in this generation. */
|
||||
public String dedupKey(long sequence) {
|
||||
if (sequence < 1) {
|
||||
throw new IllegalArgumentException("client stream sequences are 1-based");
|
||||
}
|
||||
return value + "|" + generation + "|" + sequence;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.grpc.advanced.streaming;
|
||||
|
||||
/**
|
||||
* Where a client stream is in its life.
|
||||
*
|
||||
* <p>{@link #HALF_CLOSED} is separate from {@link #COMPLETED} because the server is still working
|
||||
* between them: the client has said it will send nothing more, and the single response has not
|
||||
* arrived. A state machine that merges them cannot express "we are waiting for the result of what
|
||||
* we sent", which is the only interesting moment in a client stream.
|
||||
*/
|
||||
public enum GrpcClientStreamState {
|
||||
/** Accepting messages from the client. */
|
||||
OPEN(true),
|
||||
/** The client has finished sending; the server is producing its response. */
|
||||
HALF_CLOSED(false),
|
||||
/** The server responded. */
|
||||
COMPLETED(false),
|
||||
/** The stream ended without a response. */
|
||||
CANCELLED(false);
|
||||
|
||||
private final boolean acceptsMessages;
|
||||
|
||||
GrpcClientStreamState(boolean acceptsMessages) {
|
||||
this.acceptsMessages = acceptsMessages;
|
||||
}
|
||||
|
||||
/** Whether another client message may be accepted. */
|
||||
public boolean acceptsMessages() {
|
||||
return acceptsMessages;
|
||||
}
|
||||
|
||||
/** Whether the stream has finished, one way or another. */
|
||||
public boolean terminal() {
|
||||
return this == COMPLETED || this == CANCELLED;
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package dev.caskeleton.grpc.advanced.streaming;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Tracks demand in one direction and refuses to let it grow past its bound.
|
||||
*
|
||||
* <p>Never returns the underlying observer. Handing an application the raw {@code
|
||||
* ServerCallStreamObserver} gives it {@code request(n)} with no ceiling and {@code onNext} with no
|
||||
* serialization, which is both bounds gone at once; this controller is what the application talks
|
||||
* to instead.
|
||||
*
|
||||
* <p>The watchdog compares two moments rather than sleeping: when demand was last requested, and
|
||||
* when a message last moved. Both stalling for the timeout is the deadlock.
|
||||
*/
|
||||
public final class GrpcDemandController {
|
||||
|
||||
private final GrpcManualFlowControlPolicy policy;
|
||||
private int outstandingDemand;
|
||||
private Instant lastDemandRequestedAt;
|
||||
private Instant lastMessageAt;
|
||||
|
||||
/** Starts a controller at {@code startedAt} with no outstanding demand. */
|
||||
public GrpcDemandController(GrpcManualFlowControlPolicy policy, Instant startedAt) {
|
||||
if (policy == null || startedAt == null) {
|
||||
throw new IllegalArgumentException("a demand controller needs a policy and a start moment");
|
||||
}
|
||||
this.policy = policy;
|
||||
this.lastDemandRequestedAt = startedAt;
|
||||
this.lastMessageAt = startedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether more may be requested at {@code now}.
|
||||
*
|
||||
* @param peerWaiting whether the other direction is itself blocked waiting for this one
|
||||
*/
|
||||
public synchronized GrpcDemandDecision decide(Instant now, boolean peerWaiting) {
|
||||
if (now == null) {
|
||||
throw new IllegalArgumentException("a demand decision needs a moment");
|
||||
}
|
||||
if (peerWaiting && stalledFor(now, policy.deadlockWatchdogTimeout())) {
|
||||
return new GrpcDemandDecision(
|
||||
GrpcDemandDecision.Action.DEADLOCK_SUSPECTED,
|
||||
outstandingDemand,
|
||||
"neither direction has moved for "
|
||||
+ policy.deadlockWatchdogTimeout()
|
||||
+ " while both are waiting; the stream is open and will never progress");
|
||||
}
|
||||
if (outstandingDemand >= policy.maxOutstandingDemand()) {
|
||||
return new GrpcDemandDecision(
|
||||
GrpcDemandDecision.Action.HOLD,
|
||||
outstandingDemand,
|
||||
"outstanding demand is at its ceiling of " + policy.maxOutstandingDemand());
|
||||
}
|
||||
return new GrpcDemandDecision(
|
||||
GrpcDemandDecision.Action.REQUEST_MORE, outstandingDemand, "within the demand ceiling");
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a request for {@code count} more messages.
|
||||
*
|
||||
* @throws IllegalArgumentException when it would exceed the ceiling
|
||||
*/
|
||||
public synchronized void request(int count, Instant now) {
|
||||
if (count < 1) {
|
||||
throw new IllegalArgumentException("a demand request is for at least one message");
|
||||
}
|
||||
if (outstandingDemand + count > policy.maxOutstandingDemand()) {
|
||||
throw new IllegalArgumentException(
|
||||
"requesting "
|
||||
+ count
|
||||
+ " more would take outstanding demand to "
|
||||
+ (outstandingDemand + count)
|
||||
+ ", above the ceiling of "
|
||||
+ policy.maxOutstandingDemand());
|
||||
}
|
||||
outstandingDemand += count;
|
||||
lastDemandRequestedAt = now;
|
||||
}
|
||||
|
||||
/** Records that one message arrived, consuming a unit of demand. */
|
||||
public synchronized void messageReceived(Instant now) {
|
||||
if (outstandingDemand > 0) {
|
||||
outstandingDemand--;
|
||||
}
|
||||
lastMessageAt = now;
|
||||
}
|
||||
|
||||
/** How much demand is outstanding. */
|
||||
public synchronized int outstandingDemand() {
|
||||
return outstandingDemand;
|
||||
}
|
||||
|
||||
/** The high-water mark a metric records. */
|
||||
public synchronized int demandCeiling() {
|
||||
return policy.maxOutstandingDemand();
|
||||
}
|
||||
|
||||
private boolean stalledFor(Instant now, Duration timeout) {
|
||||
return !now.isBefore(lastMessageAt.plus(timeout))
|
||||
&& !now.isBefore(lastDemandRequestedAt.plus(timeout));
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package dev.caskeleton.grpc.advanced.streaming;
|
||||
|
||||
/**
|
||||
* Whether more inbound messages may be requested, or more outbound produced.
|
||||
*
|
||||
* <p>{@link Action#DEADLOCK_SUSPECTED} is a real answer rather than a diagnostic. Manual flow
|
||||
* control makes it possible for both sides to be waiting for the other, and that state has no
|
||||
* timeout of its own: the stream is healthy, the connection is open, and nothing will ever move
|
||||
* again. Naming it is what lets a watchdog end the stream instead of leaking it.
|
||||
*/
|
||||
public record GrpcDemandDecision(Action action, int outstandingDemand, String reason) {
|
||||
|
||||
/** What the caller should do. */
|
||||
public enum Action {
|
||||
/** Request more inbound messages, or produce more outbound ones. */
|
||||
REQUEST_MORE,
|
||||
/** Hold: the outstanding demand is already at its bound. */
|
||||
HOLD,
|
||||
/** Neither side can move; end the stream. */
|
||||
DEADLOCK_SUSPECTED
|
||||
}
|
||||
|
||||
/** Requires a reason and a non-negative demand. */
|
||||
public GrpcDemandDecision {
|
||||
if (action == null) {
|
||||
throw new IllegalArgumentException("a demand decision has an action");
|
||||
}
|
||||
if (outstandingDemand < 0) {
|
||||
throw new IllegalArgumentException("outstanding demand must not be negative");
|
||||
}
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new IllegalArgumentException("a demand decision explains itself");
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the caller may ask for more. */
|
||||
public boolean mayRequestMore() {
|
||||
return action == Action.REQUEST_MORE;
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package dev.caskeleton.grpc.advanced.streaming;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* The bounds a manually flow-controlled stream runs under.
|
||||
*
|
||||
* <p>Manual flow control hands the application the {@code request(n)} call, which is exactly the
|
||||
* knob automatic flow control exists to turn correctly. The two bounds here are what keep that from
|
||||
* becoming unbounded buffering: a ceiling on outstanding demand, and a watchdog for the state where
|
||||
* both sides are waiting.
|
||||
*
|
||||
* <p>Approval is a field because this capability is granted per method, not per service. A method
|
||||
* that reads a large result set benefits; the one next to it does not, and enabling both because
|
||||
* they share a service is how the second one acquires a bug nobody was looking for.
|
||||
*/
|
||||
public record GrpcManualFlowControlPolicy(
|
||||
int maxOutstandingDemand, Duration deadlockWatchdogTimeout, boolean approvedForMethod) {
|
||||
|
||||
/** Refuses an unapproved or unbounded manual-flow-control policy. */
|
||||
public GrpcManualFlowControlPolicy {
|
||||
if (maxOutstandingDemand < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"manual flow control needs a demand ceiling; without one, request(n) is unbounded "
|
||||
+ "buffering with extra steps");
|
||||
}
|
||||
if (deadlockWatchdogTimeout == null
|
||||
|| deadlockWatchdogTimeout.isZero()
|
||||
|| deadlockWatchdogTimeout.isNegative()) {
|
||||
throw new IllegalArgumentException(
|
||||
"manual flow control needs a watchdog; a stream where both sides wait for the other has "
|
||||
+ "no timeout of its own");
|
||||
}
|
||||
if (!approvedForMethod) {
|
||||
throw new IllegalArgumentException(
|
||||
"manual flow control is granted per method; an unapproved policy would enable it for "
|
||||
+ "every method on the service");
|
||||
}
|
||||
}
|
||||
|
||||
/** A policy for an approved method. */
|
||||
public static GrpcManualFlowControlPolicy forApprovedMethod(int maxOutstandingDemand) {
|
||||
return new GrpcManualFlowControlPolicy(maxOutstandingDemand, Duration.ofSeconds(30), true);
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package dev.caskeleton.grpc.advanced.streaming;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.streaming.GrpcFlowControlPolicy;
|
||||
import dev.caskeleton.grpc.streaming.GrpcSlowConsumerPolicy;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcBidiSequenceTrackerTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("the two directions have independent sequences")
|
||||
void directionsAreIndependent() {
|
||||
GrpcBidiSequenceTracker tracker = new GrpcBidiSequenceTracker();
|
||||
|
||||
assertThat(tracker.nextClientSequence()).isEqualTo(1L);
|
||||
assertThat(tracker.nextClientSequence()).isEqualTo(2L);
|
||||
assertThat(tracker.nextServerSequence()).isEqualTo(1L);
|
||||
assertThat(tracker.clientToServer().lastSequence()).isEqualTo(2L);
|
||||
assertThat(tracker.serverToClient().lastSequence()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("half-closing one direction leaves the other open")
|
||||
void halfClosingOneDirectionLeavesTheOtherOpen() {
|
||||
GrpcBidiSequenceTracker tracker = new GrpcBidiSequenceTracker();
|
||||
tracker.halfCloseClient();
|
||||
|
||||
assertThat(tracker.clientToServer().ended()).isTrue();
|
||||
assertThat(tracker.serverToClient().ended()).isFalse();
|
||||
assertThat(tracker.bothEnded()).isFalse();
|
||||
assertThat(tracker.nextServerSequence()).isEqualTo(1L);
|
||||
|
||||
tracker.halfCloseServer();
|
||||
assertThat(tracker.bothEnded()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cancelling one direction leaves the other alone")
|
||||
void cancellingOneDirectionLeavesTheOtherAlone() {
|
||||
GrpcBidiSequenceTracker tracker = new GrpcBidiSequenceTracker();
|
||||
|
||||
tracker.cancelServer();
|
||||
|
||||
assertThat(tracker.serverToClient().cancelled()).isTrue();
|
||||
assertThat(tracker.clientToServer().ended()).isFalse();
|
||||
assertThat(tracker.nextClientSequence()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a message after half-close is a protocol error, not a late arrival")
|
||||
void aMessageAfterHalfCloseIsAnError() {
|
||||
GrpcBidiSequenceTracker tracker = new GrpcBidiSequenceTracker();
|
||||
tracker.halfCloseClient();
|
||||
|
||||
assertThatThrownBy(tracker::nextClientSequence)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("protocol error");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("half-close and cancel are different endings")
|
||||
void halfCloseAndCancelAreDifferent() {
|
||||
assertThatThrownBy(() -> new GrpcBidiDirectionState(1L, true, true))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("different endings");
|
||||
assertThat(GrpcBidiDirectionState.open().cancel().ended()).isTrue();
|
||||
assertThat(GrpcBidiDirectionState.open().halfClose().ended()).isTrue();
|
||||
assertThatThrownBy(() -> GrpcBidiDirectionState.open().cancel().halfClose())
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a bidi resume carries both applied positions and requires a matching generation")
|
||||
void aBidiResumeCarriesBothPositions() {
|
||||
GrpcClientStreamSessionId session = GrpcClientStreamSessionId.newSession();
|
||||
GrpcBidiSession bidi =
|
||||
new GrpcBidiSession(
|
||||
session, GrpcFlowControlPolicy.stable(), GrpcFlowControlPolicy.stable());
|
||||
bidi.sequences().nextClientSequence();
|
||||
bidi.sequences().nextServerSequence();
|
||||
bidi.sequences().nextServerSequence();
|
||||
|
||||
GrpcBidiResumeState state = bidi.resumeState();
|
||||
|
||||
assertThat(state.clientAppliedSequence()).isEqualTo(1L);
|
||||
assertThat(state.serverAppliedSequence()).isEqualTo(2L);
|
||||
assertThat(bidi.resumeFrom(session)).isPresent();
|
||||
assertThat(bidi.resumeFrom(session.resumed())).isEmpty();
|
||||
assertThat(state.resumed().sessionId().generation()).isEqualTo(2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("each direction carries its own flow-control policy")
|
||||
void eachDirectionHasItsOwnFlowControl() {
|
||||
GrpcFlowControlPolicy inbound =
|
||||
new GrpcFlowControlPolicy(8, 1024L, 4, GrpcSlowConsumerPolicy.TERMINATE);
|
||||
GrpcBidiSession bidi =
|
||||
new GrpcBidiSession(
|
||||
GrpcClientStreamSessionId.newSession(), inbound, GrpcFlowControlPolicy.stable());
|
||||
|
||||
assertThat(bidi.inboundFlowControl().maxQueuedMessages()).isEqualTo(8);
|
||||
assertThat(bidi.outboundFlowControl().maxQueuedMessages()).isEqualTo(256);
|
||||
assertThat(bidi.complete()).isFalse();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user