feat: grpc 기능 deep 구현

This commit is contained in:
DongHyeonka
2026-08-31 07:28:39 +09:00
parent a24ece9cf7
commit 21234e38cd
400 changed files with 40217 additions and 4 deletions
+7 -4
View File
@@ -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 {
+197
View File
@@ -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": []
}
]
}
+77
View File
@@ -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
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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=
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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();
}
});
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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();
}
});
}
}
@@ -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;
}
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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());
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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 }
@@ -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();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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=
@@ -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);
}
}
@@ -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);
}
}
@@ -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");
});
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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;
}
@@ -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);
}
}
}
@@ -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=
@@ -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);
}
}
@@ -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;
}
}
@@ -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());
}
}
@@ -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);
}
}
@@ -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()));
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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());
}
}
@@ -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;
}
}
@@ -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);
});
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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
}
}
@@ -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");
}
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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");
}
}
@@ -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=
@@ -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;
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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