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
+117
View File
@@ -0,0 +1,117 @@
# grpc — local authority for the gRPC platform family
이 문서는 `grpc:*` family의 **local authority**다. leaf 목록·gradle path·허용 의존성은
`src/config/architecture/modules.json`이 SSOT이며 이 문서는 그것을 복제하지 않는다. 여기서 정하는
것은 registry가 표현할 수 없는 것들이다: family별 framework 규칙, 증거 등급, Stable 범위의 경계,
그리고 `runtime_memberships`가 비어 있다는 사실의 의미.
Root 정책(`CLAUDE.md` / `AGENTS.md`)과 충돌하면 root가 이긴다.
이 family는 Clean Architecture의 계층이 아니라 **벤더드 RPC 플랫폼**이다 — `messaging:*`와 같은
자리에 있고 같은 이유로 있다. 자기 API(port)와 adapter와 조립 경계(starter)를 가지며, 애플리케이션은
`adapter:inbound:grpc`를 통해 라이브러리로서 도달한다.
설계 근거와 원본 계획 대비 deviation은
[docs/superpowers/specs/2026-08-30-grpc-platform-adaptation-design.md](../../docs/superpowers/specs/2026-08-30-grpc-platform-adaptation-design.md)가 SSOT다.
## Family와 framework 규칙
leaf의 정확한 id는 registry에서 읽는다.
| Family | 역할 | 허용 framework |
| --- | --- | --- |
| `grpc-core-api` | 식별자, method policy, 실행 증거, 실패 모델, deadline, request context, operation ledger port | **Java stdlib만.** io.grpc / Spring / protobuf / DB 타입 금지 |
| `grpc-proto-contract` | `.proto` 스키마 소스와 style/schema 규칙 검증기 | Java stdlib만 |
| `grpc-codegen` | Buf governance, codegen manifest, descriptor 릴리스 아티팩트 | Java stdlib만 |
| `grpc-policy` | validation, context, status/rich error, TLS/credential, deadline/cancellation, retry, idempotency, streaming, size/compression | `io.grpc:grpc-api`, `grpc-stub`. Spring 금지 |
| `grpc-server` | application boundary rule, typed service adapter SPI, interceptor 순서, Netty profile | `io.grpc:grpc-api`, `grpc-stub`. Netty 자체 금지 — profile은 설정 모델이다 |
| `grpc-client` | named channel, runtime generation, typed stub factory, client metadata/credentials | `io.grpc:grpc-api`, `grpc-stub` |
| `grpc-discovery` | Static/DNS resolver, pick_first/round_robin, Kubernetes routing profile | Java stdlib만 |
| `grpc-admin` | health, reflection, drain, secret-free policy snapshot | Java stdlib만 |
| `grpc-observability` | bounded metric/trace convention | `io.micrometer:micrometer-core`만 |
| `grpc-operation-ledger-jpa` | mutation idempotency의 durable 저장 | `jakarta.persistence`, `spring-data-jpa`. **port는 core-api에 있다** |
| `grpc-spring-boot-starter` | 조립 경계, typed properties, startup validator | Spring Boot autoconfigure. 여기서만 |
| `grpc-testkit` | in-process / 실제 Netty / fault / performance 인증 | io.grpc netty-shaded, inprocess. production leaf가 의존하지 않는다 |
`grpc-core-api`가 io.grpc를 이름조차 부르지 않는다는 것이 이 family의 핵심 제약이다. 실행 증거와
실패 모델은 "전송이 무엇을 했는가"를 기술하는 계약이고, 그 계약 안에 전송 타입이 들어가면 계약이
전송의 일부가 된다. `GrpcStatusCode`가 io.grpc의 코드를 미러링하는 이유가 이것이고, 양방향 번역은
`grpc-policy``GrpcStatusMapping`이 단독으로 소유한다.
## 증거 등급 — testkit이 하나의 leaf인 이유
원본 계획은 testkit을 core/in-process/netty/fault 4개 모듈로 쪼갠다. 목적은 "in-process 결과를
네트워크 증거로 오해하지 않게 한다"이고, 이 저장소는 그 목적을 모듈 경계가 아니라 **strict test
lane**으로 표현한다(`ca.strict-test-lane`: lane은 아무것도 실행하지 않으면 실패하고 up-to-date
결과를 제공하지 않는다).
`grpc-testkit`의 네 lane과 그 태그:
| lane | 태그 | 증명하는 것 |
| --- | --- | --- |
| `grpcInProcessContractTest` | `grpc-inprocess` | adapter, interceptor 순서, status, validation, idempotency replay |
| `grpcNettyContractTest` | `grpc-netty` | 실제 소켓 위의 HTTP/2, TLS/mTLS, metadata·message hard limit, GOAWAY, drain |
| `grpcFaultTest` | `grpc-fault` | 각 증거 경계에서의 연결 손실, completion unknown, partial stream |
| `grpcPerformanceTest` | `grpc-performance` | latency percentile, saturation, drain budget |
lane 분리만으로는 리포트가 잘못된 run을 인용하는 것을 막지 못하므로 `GrpcEvidenceGrade`가 런타임
체크로 이를 붙든다: `CONTRACT` 등급으로 `tls`를 주장하면 예외다.
**performance lane은 기본 `test`에서 제외된다**(leaf `build.gradle`). 공유 CI runner에서의 측정은
release path의 flaky gate가 되고, flaky gate는 꺼진다.
## Stable 범위
Stable RPC 유형은 **Unary와 Server Streaming뿐**이다. Client Streaming, Bidirectional Streaming,
hedging, xDS, custom resolver/LB, gRPC-Web, Servlet, Reactor, Kotlin은 전부 `grpc-advanced:*`이며
capability flag 뒤에 있다. `GrpcPlatformStartupValidator`가 Stable catalog에 streaming method가
등록되면 startup을 거부한다.
`grpc-spring-boot-starter`는 어떤 advanced module도 참조하지 않는다. registry의
`allowed_dependencies`가 이를 build time에, `GrpcStableBuildInvariant`가 runtime에 강제한다.
## Runtime membership이 비어 있다는 것의 의미
registry의 모든 `grpc:*` leaf는 현재 `runtime_memberships`가 비어 있다. `messaging:*`가 처음
착지했을 때와 같은 상태다:
- **배포 아티팩트가 싣고 있지 않다.** build-only다.
- 애플리케이션에 배선하려면 registry의 `runtime_memberships`를 먼저 바꾸고
`verifyRuntimeModuleMembership`을 통과시켜야 한다. 코드만 추가하는 것으로는 런타임에 들어가지 않는다.
- **런타임에 있다는 것과 자격이 증명됐다는 것은 다르다.** 현재 in-process·Netty·fault lane은 실제로
실행되어 통과하지만, 실제 배포 환경에서의 soak·performance baseline은 없다. `GrpcStableReleaseGate`
그 구분을 문서가 아니라 코드로 붙들고 있다.
## application이 이 family에 도달하는 경로
`messaging:*`의 MSG-015(bridge 부재)를 반복하지 않는 것이 이 family의 목표다. 계약상의 경로는
```text
application-owned port → adapter:inbound:grpc (typed service adapter) → :grpc:grpc-server SPI
```
이고 `GrpcApplicationBoundaryRules`·`GrpcRawApiImportRule`·`GrpcServiceAdapter`가 그 경계를
소유한다. 현재 `adapter:inbound:grpc`는 이 family에 의존하지 않는다 — registry의
`allowed_dependencies`를 보라. 배선은 별도 결정이며, 그때 registry edge와 브리지가 함께 들어간다.
## 테스트 명령
focused test는 registry의 `gradle_path`에서 파생한다.
```bash
cd src
./gradlew :grpc:<leaf>:test --console=plain
./gradlew :grpc:grpc-testkit:grpcInProcessContractTest --console=plain
./gradlew :grpc:grpc-testkit:grpcNettyContractTest --console=plain
./gradlew :grpc:grpc-testkit:grpcFaultTest --console=plain
./gradlew :grpc:grpc-testkit:grpcPerformanceTest --console=plain
```
## 금지
- `grpc-core-api`에서 io.grpc / Spring / protobuf / JPA 타입 사용.
- Stable leaf에서 `:grpc-advanced:*` 참조.
- `grpc-testkit`을 production leaf가 의존하는 것.
- in-process 결과로 transport capability를 주장하는 것 (`GrpcEvidenceGrade`가 거부한다).
- metric tag에 raw metadata·payload·actor/tenant/object/stream/idempotency 식별자를 넣는 것
(`GrpcMetricCardinalityPolicy`가 거부한다).
+8
View File
@@ -0,0 +1,8 @@
apply plugin: 'java-library'
// Operational surface: the standard health registry, the reflection exposure policy, the drain
// coordinator, and the secret-free runtime policy snapshot an administrator reads.
dependencies {
api project(':grpc:grpc-core-api')
api project(':grpc:grpc-server')
}
+91
View File
@@ -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,71 @@
package dev.caskeleton.grpc.admin;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* What an admin snapshot is allowed to contain, and who may read one.
*
* <p>The denylist is keyed on field names rather than values because a snapshot is assembled from
* configuration, and configuration keys are named after what they hold. A key called {@code
* client-secret} does not need its value inspected to be refused.
*
* <p>Both gates are required for the same reason reflection needs both: an admin role reachable
* from the public network is not an admin gate.
*/
public record GrpcAdminExposurePolicy(Set<String> adminNetworks, Set<String> adminRoles) {
private static final Pattern SECRET_FIELD =
Pattern.compile(
"(?i).*(password|secret|token|credential|private[_-]?key|api[_-]?key|passphrase|"
+ "authorization|bearer).*");
/** Refuses a policy with nothing to check. */
public GrpcAdminExposurePolicy {
if (adminNetworks == null || adminRoles == null) {
throw new IllegalArgumentException("an admin policy states both gates");
}
adminNetworks = Set.copyOf(adminNetworks);
adminRoles = Set.copyOf(adminRoles);
if (adminNetworks.isEmpty() || adminRoles.isEmpty()) {
throw new IllegalArgumentException(
"an admin surface needs both a network and a role gate; either alone leaves the snapshot "
+ "reachable by whoever satisfies the other");
}
}
/** This repository's default. */
public static GrpcAdminExposurePolicy standard() {
return new GrpcAdminExposurePolicy(Set.of("admin"), Set.of("ROLE_PLATFORM_ADMIN"));
}
/** Whether this caller may read an admin snapshot. */
public boolean mayRead(String callerNetwork, Set<String> callerRoles) {
return callerNetwork != null
&& adminNetworks.contains(callerNetwork)
&& callerRoles != null
&& callerRoles.stream().anyMatch(adminRoles::contains);
}
/** Whether a field name is one whose value must never appear in a snapshot. */
public static boolean secretField(String fieldName) {
return fieldName != null && SECRET_FIELD.matcher(fieldName).matches();
}
/**
* Every field in {@code candidate} that must not be published.
*
* @return an empty list when the map is safe to expose
*/
public static List<String> forbiddenFields(Map<String, String> candidate) {
if (candidate == null) {
throw new IllegalArgumentException("a candidate snapshot map is required");
}
return candidate.keySet().stream()
.filter(GrpcAdminExposurePolicy::secretField)
.sorted()
.toList();
}
}
@@ -0,0 +1,162 @@
package dev.caskeleton.grpc.admin;
import dev.caskeleton.grpc.server.GrpcAdmissionController;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.function.IntSupplier;
/**
* Runs the shutdown sequence in order and reports what it achieved.
*
* <p>Deterministic on purpose. A shutdown assembled from lifecycle callbacks runs its steps in
* whatever order the container happens to resolve them, and the ordering bug — refusing before
* routing has stopped — only appears under real traffic, which is the one condition a test does not
* reproduce.
*
* <p>The coordinator does not sleep. It is given the current moment and the counts, and returns
* whether the phase is done; the waiting belongs to the caller, which is what makes every branch of
* this testable without a clock.
*/
public final class GrpcDrainCoordinator {
private final GrpcServiceHealthRegistry health;
private final GrpcAdmissionController admission;
private final GrpcDrainPolicy policy;
private final IntSupplier inFlightUnaryCalls;
private final IntSupplier openStreams;
private final List<GrpcDrainPhase> phasesRun = new ArrayList<>();
private Instant startedAt;
private int completedUnaryCalls;
private int signalledStreams;
/**
* @param inFlightUnaryCalls how many unary calls are still running, asked each time rather than
* captured, so the coordinator sees the number shrink
* @param openStreams how many streams are still open
*/
public GrpcDrainCoordinator(
GrpcServiceHealthRegistry health,
GrpcAdmissionController admission,
GrpcDrainPolicy policy,
IntSupplier inFlightUnaryCalls,
IntSupplier openStreams) {
if (health == null
|| admission == null
|| policy == null
|| inFlightUnaryCalls == null
|| openStreams == null) {
throw new IllegalArgumentException("a drain coordinator needs all five collaborators");
}
this.health = health;
this.admission = admission;
this.policy = policy;
this.inFlightUnaryCalls = inFlightUnaryCalls;
this.openStreams = openStreams;
}
/**
* Runs the first two phases: stop being routed to, then say why.
*
* <p>Nothing is refused yet, deliberately.
*/
public void beginDrain(Instant now) {
if (now == null) {
throw new IllegalArgumentException("a drain needs a starting moment");
}
if (startedAt != null) {
return;
}
startedAt = now;
health.beginDraining();
phasesRun.add(GrpcDrainPhase.READINESS_FALSE);
phasesRun.add(GrpcDrainPhase.HEALTH_DRAINING);
}
/**
* Starts refusing new calls.
*
* @throws IllegalStateException when called before {@link #beginDrain}, which would refuse
* traffic that routing is still sending
*/
public void rejectNewAdmission() {
requireStarted();
phasesRun.add(GrpcDrainPhase.REJECT_NEW_ADMISSION);
}
/**
* Whether the unary drain is finished at {@code now}.
*
* @return true when nothing is in flight or the budget has elapsed
*/
public boolean unaryDrainComplete(Instant now) {
requireStarted();
if (!phasesRun.contains(GrpcDrainPhase.DRAIN_UNARY)) {
phasesRun.add(GrpcDrainPhase.DRAIN_UNARY);
}
int remaining = inFlightUnaryCalls.getAsInt();
if (remaining == 0) {
return true;
}
return elapsed(now).compareTo(policy.unaryDrainBudget()) >= 0;
}
/** Records that each open stream has been sent its terminal message. */
public void signalStreams() {
requireStarted();
signalledStreams = openStreams.getAsInt();
phasesRun.add(GrpcDrainPhase.SIGNAL_STREAMS);
}
/** Whether the stream signal budget has elapsed at {@code now}. */
public boolean streamSignalComplete(Instant now) {
requireStarted();
return openStreams.getAsInt() == 0
|| elapsed(now).compareTo(policy.unaryDrainBudget().plus(policy.streamSignalBudget())) >= 0;
}
/** Records how many unary calls finished on their own. */
public void recordCompletedUnaryCalls(int completed) {
if (completed < 0) {
throw new IllegalArgumentException("a completed count must not be negative");
}
this.completedUnaryCalls = completed;
}
/** Cancels whatever is left and returns the report. */
public GrpcDrainResult forceCancel(Instant now) {
requireStarted();
phasesRun.add(GrpcDrainPhase.FORCE_CANCEL);
return new GrpcDrainResult(
completedUnaryCalls,
inFlightUnaryCalls.getAsInt(),
signalledStreams,
openStreams.getAsInt(),
elapsed(now),
phasesRun);
}
/** Whether new calls are still being admitted. */
public boolean admittingNewCalls() {
return !phasesRun.contains(GrpcDrainPhase.REJECT_NEW_ADMISSION);
}
/** How many calls the admission controller currently holds. */
public int inFlightAdmitted() {
return admission.inFlight();
}
private Duration elapsed(Instant now) {
return Duration.between(startedAt, now);
}
private void requireStarted() {
if (startedAt == null) {
throw new IllegalStateException(
"the drain has not begun; refusing calls before readiness has flipped produces errors "
+ "for traffic that routing is still sending");
}
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.grpc.admin;
/**
* The shutdown sequence, in the order it must run.
*
* <p>Readiness comes first, before anything is refused, because there is a gap between an instance
* saying it is unready and a load balancer acting on it. Refusing during that gap turns a clean
* rollout into a burst of errors at every deployment.
*
* <p>Streams are signalled rather than waited for. A subscription may legitimately never end, so a
* drain that waits for one waits forever; what it gets instead is a terminal message carrying its
* resume cursor, which is the difference between a client that reconnects where it left off and one
* that resynchronises from scratch.
*/
public enum GrpcDrainPhase {
/** Report unready, so routing stops sending. Nothing is refused yet. */
READINESS_FALSE,
/** Flip health to draining, which a load balancer reads differently from a failure. */
HEALTH_DRAINING,
/** Refuse new calls. */
REJECT_NEW_ADMISSION,
/** Wait for in-flight unary calls to finish. */
DRAIN_UNARY,
/** Send each stream its terminal message and resume cursor. */
SIGNAL_STREAMS,
/** Cancel whatever is left. */
FORCE_CANCEL
}
@@ -0,0 +1,43 @@
package dev.caskeleton.grpc.admin;
import java.time.Duration;
/**
* The two budgets a drain runs against.
*
* <p>Separate because they end differently. The drain budget is time given to work that will finish
* on its own; the force budget is time given to work being told to stop. One number for both means
* either cancelling calls that were about to complete, or waiting the full budget for streams that
* were never going to.
*/
public record GrpcDrainPolicy(
Duration unaryDrainBudget, Duration streamSignalBudget, Duration forceCancelBudget) {
/** Refuses a policy with no bound on how long shutdown takes. */
public GrpcDrainPolicy {
requireNonNegative(unaryDrainBudget, "unary drain budget");
requireNonNegative(streamSignalBudget, "stream signal budget");
requireNonNegative(forceCancelBudget, "force cancel budget");
if (unaryDrainBudget.isZero() && streamSignalBudget.isZero() && forceCancelBudget.isZero()) {
throw new IllegalArgumentException(
"a drain with no budget at all cancels every in-flight call at the first phase");
}
}
private static void requireNonNegative(Duration value, String what) {
if (value == null || value.isNegative()) {
throw new IllegalArgumentException(what + " must be present and non-negative");
}
}
/** The Stable default. */
public static GrpcDrainPolicy stable() {
return new GrpcDrainPolicy(
Duration.ofSeconds(15), Duration.ofSeconds(5), Duration.ofSeconds(5));
}
/** The longest a full drain can take. */
public Duration totalBudget() {
return unaryDrainBudget.plus(streamSignalBudget).plus(forceCancelBudget);
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.grpc.admin;
import java.time.Duration;
import java.util.List;
/**
* What a drain actually achieved.
*
* <p>Counted rather than assumed, because "graceful shutdown" is a claim and these are the numbers
* that support or refute it. A deployment whose drains routinely force-cancel is a deployment whose
* clients see errors at every rollout, and without the count that shows up as unexplained {@code
* CANCELLED} in a dashboard nobody connects to the deploy.
*/
public record GrpcDrainResult(
int completedUnaryCalls,
int cancelledUnaryCalls,
int signalledStreams,
int cancelledStreams,
Duration elapsed,
List<GrpcDrainPhase> phasesRun) {
/** Requires non-negative counts and the phase list. */
public GrpcDrainResult {
if (completedUnaryCalls < 0
|| cancelledUnaryCalls < 0
|| signalledStreams < 0
|| cancelledStreams < 0) {
throw new IllegalArgumentException("drain counts must not be negative");
}
if (elapsed == null || elapsed.isNegative()) {
throw new IllegalArgumentException("a drain records how long it took");
}
if (phasesRun == null || phasesRun.isEmpty()) {
throw new IllegalArgumentException("a drain records the phases it ran");
}
phasesRun = List.copyOf(phasesRun);
}
/** Whether anything had to be cut off. */
public boolean forcedAnything() {
return cancelledUnaryCalls > 0 || cancelledStreams > 0;
}
/** Whether every phase ran. */
public boolean complete() {
return phasesRun.containsAll(List.of(GrpcDrainPhase.values()));
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.grpc.admin;
import java.util.Set;
/**
* Which dependencies a service's health actually depends on.
*
* <p>The distinction the policy exists to hold: a dependency belongs here only when the service
* cannot answer correctly without it. Reporting unhealthy because a non-essential dependency is
* down takes an instance out of rotation that could still serve most of its traffic — and in a
* shared dependency outage, it takes every instance out at once, turning a partial degradation into
* a total one.
*/
public record GrpcHealthPolicy(
Set<String> correctnessCriticalDependencies, boolean readyBeforeFirstCheck) {
/** Copies the dependency set. */
public GrpcHealthPolicy {
if (correctnessCriticalDependencies == null) {
throw new IllegalArgumentException(
"a health policy states its critical dependencies, even if none");
}
correctnessCriticalDependencies = Set.copyOf(correctnessCriticalDependencies);
if (readyBeforeFirstCheck) {
throw new IllegalArgumentException(
"readiness must be false until the first check completes; an instance that reports ready "
+ "at process start receives traffic before it can serve it");
}
}
/** A policy with no correctness-critical dependency. */
public static GrpcHealthPolicy standalone() {
return new GrpcHealthPolicy(Set.of(), false);
}
/** A policy whose service cannot answer correctly without the named dependencies. */
public static GrpcHealthPolicy dependingOn(Set<String> dependencies) {
return new GrpcHealthPolicy(dependencies, false);
}
/**
* Whether {@code dependencyName} being down should make this service unhealthy.
*
* <p>False for anything not declared critical, deliberately.
*/
public boolean affectsHealth(String dependencyName) {
return correctnessCriticalDependencies.contains(dependencyName);
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.grpc.admin;
/**
* What a health check reports about one service.
*
* <p>{@link #DRAINING} is the addition to the standard set, and it earns its place: a load balancer
* that sees {@code NOT_SERVING} has no way to tell "this instance is broken" from "this instance is
* finishing its work and going away", and the two call for opposite responses — one is an incident,
* the other is a rollout proceeding correctly.
*/
public enum GrpcHealthState {
/** Nothing is known yet. The state before the first check completes. */
UNKNOWN(false),
/** Serving normally. */
SERVING(true),
/** Not serving, and not deliberately. */
NOT_SERVING(false),
/** The service is not registered here. */
SERVICE_UNKNOWN(false),
/** Deliberately winding down. Send no new work; existing work is finishing. */
DRAINING(false);
private final boolean acceptsNewWork;
GrpcHealthState(boolean acceptsNewWork) {
this.acceptsNewWork = acceptsNewWork;
}
/** Whether new requests should be routed here. */
public boolean acceptsNewWork() {
return acceptsNewWork;
}
/** The standard gRPC health value a client sees, since DRAINING has no standard equivalent. */
public GrpcHealthState standardEquivalent() {
return this == DRAINING ? NOT_SERVING : this;
}
}
@@ -0,0 +1,71 @@
package dev.caskeleton.grpc.admin;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* What the platform is running right now, as an operator sees it.
*
* <p>Hashes rather than contents for the policy catalog and the channel profiles. An operator's
* question is "is this instance running the configuration we released", and a hash answers it
* without publishing every deadline, every target and every retry rule to whoever can reach the
* admin endpoint.
*
* <p>Versioned and timestamped so two snapshots can be compared. An undated snapshot answers "what
* is running" and not "what changed", and the second is the question during an incident.
*/
public record GrpcPlatformSnapshot(
String snapshotVersion,
Instant capturedAt,
String schemaVersion,
String methodPolicyHash,
List<String> registeredServices,
Map<String, String> channelProfileHashes,
Map<String, String> resolverAndLoadBalancerByChannel,
Map<String, String> retryOwnerByChannel,
Map<String, GrpcHealthState> serviceHealth,
GrpcReflectionMode reflectionMode,
boolean draining) {
/** Copies every collection and refuses an unversioned snapshot. */
public GrpcPlatformSnapshot {
if (snapshotVersion == null || snapshotVersion.isBlank()) {
throw new IllegalArgumentException(
"a snapshot is versioned; an undated one answers what is running but not what changed");
}
if (capturedAt == null) {
throw new IllegalArgumentException("a snapshot records when it was taken");
}
if (schemaVersion == null || methodPolicyHash == null) {
throw new IllegalArgumentException("a snapshot carries the schema version and policy hash");
}
if (registeredServices == null
|| channelProfileHashes == null
|| resolverAndLoadBalancerByChannel == null
|| retryOwnerByChannel == null
|| serviceHealth == null
|| reflectionMode == null) {
throw new IllegalArgumentException("every snapshot section must be present");
}
registeredServices = List.copyOf(registeredServices);
channelProfileHashes = Map.copyOf(channelProfileHashes);
resolverAndLoadBalancerByChannel = Map.copyOf(resolverAndLoadBalancerByChannel);
retryOwnerByChannel = Map.copyOf(retryOwnerByChannel);
serviceHealth = Map.copyOf(serviceHealth);
List<String> forbidden = GrpcAdminExposurePolicy.forbiddenFields(channelProfileHashes);
if (!forbidden.isEmpty()) {
throw new IllegalArgumentException(
"a platform snapshot must not carry " + forbidden + "; hashes and names only");
}
}
/** Whether this snapshot describes the same configuration as {@code other}. */
public boolean sameConfigurationAs(GrpcPlatformSnapshot other) {
return other != null
&& schemaVersion.equals(other.schemaVersion())
&& methodPolicyHash.equals(other.methodPolicyHash())
&& channelProfileHashes.equals(other.channelProfileHashes());
}
}
@@ -0,0 +1,115 @@
package dev.caskeleton.grpc.admin;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* Produces platform snapshots for authorized readers, and compares one against a release manifest.
*
* <p>The drift comparison is what makes the snapshot operationally useful rather than decorative.
* An instance running a configuration the release did not ship is the state behind a whole class of
* incidents that are otherwise diagnosed by reading logs — a stale config map, a rollback that only
* half applied, an environment override nobody remembered.
*/
public final class GrpcPlatformSnapshotService {
private final GrpcAdminExposurePolicy exposurePolicy;
private final GrpcServiceHealthRegistry health;
private final GrpcReflectionPolicy reflectionPolicy;
/** Binds the service to its gates and the state it reports. */
public GrpcPlatformSnapshotService(
GrpcAdminExposurePolicy exposurePolicy,
GrpcServiceHealthRegistry health,
GrpcReflectionPolicy reflectionPolicy) {
if (exposurePolicy == null || health == null || reflectionPolicy == null) {
throw new IllegalArgumentException(
"a snapshot service needs its policy, health and reflection");
}
this.exposurePolicy = exposurePolicy;
this.health = health;
this.reflectionPolicy = reflectionPolicy;
}
/**
* A snapshot, if the caller may read one.
*
* @return empty when the caller fails either gate. Empty rather than a redacted snapshot: a
* partial answer tells an unauthorized caller which services exist.
*/
public Optional<GrpcPlatformSnapshot> capture(
String callerNetwork,
Set<String> callerRoles,
String snapshotVersion,
String schemaVersion,
String methodPolicyHash,
List<String> registeredServices,
Map<String, String> channelProfileHashes,
Map<String, String> resolverAndLoadBalancerByChannel,
Map<String, String> retryOwnerByChannel,
Instant now) {
if (!exposurePolicy.mayRead(callerNetwork, callerRoles)) {
return Optional.empty();
}
return Optional.of(
new GrpcPlatformSnapshot(
snapshotVersion,
now,
schemaVersion,
methodPolicyHash,
registeredServices,
channelProfileHashes,
resolverAndLoadBalancerByChannel,
retryOwnerByChannel,
health.snapshot(),
reflectionPolicy.mode(),
health.globalState() == GrpcHealthState.DRAINING));
}
/**
* How a running snapshot differs from what a release shipped.
*
* @return an empty list when the instance is running what was released
*/
public static List<String> driftAgainstRelease(
GrpcPlatformSnapshot running, GrpcPlatformSnapshot released) {
if (running == null || released == null) {
throw new IllegalArgumentException("a drift comparison needs both snapshots");
}
List<String> drift = new java.util.ArrayList<>();
if (!running.schemaVersion().equals(released.schemaVersion())) {
drift.add(
"schema version is "
+ running.schemaVersion()
+ " but the release shipped "
+ released.schemaVersion());
}
if (!running.methodPolicyHash().equals(released.methodPolicyHash())) {
drift.add("the method policy hash differs from the released one");
}
released
.channelProfileHashes()
.forEach(
(channel, releasedHash) -> {
String runningHash = running.channelProfileHashes().get(channel);
if (runningHash == null) {
drift.add("channel profile '" + channel + "' is missing from the running instance");
} else if (!runningHash.equals(releasedHash)) {
drift.add("channel profile '" + channel + "' differs from the released one");
}
});
running
.channelProfileHashes()
.keySet()
.forEach(
channel -> {
if (!released.channelProfileHashes().containsKey(channel)) {
drift.add("channel profile '" + channel + "' is not in the release");
}
});
return List.copyOf(drift);
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.grpc.admin;
/**
* Whether one caller may use reflection.
*
* <p>Carries a reason so a refused developer learns which condition failed. Reflection being off in
* production is a decision somebody made; a caller who cannot tell that from a misconfiguration
* files a bug against the wrong thing.
*/
public record GrpcReflectionAccessDecision(boolean allowed, String reason) {
/** Requires a reason either way. */
public GrpcReflectionAccessDecision {
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("a reflection decision explains itself");
}
}
/** Reflection is available to this caller. */
public static GrpcReflectionAccessDecision allow(String reason) {
return new GrpcReflectionAccessDecision(true, reason);
}
/** Reflection is not available to this caller. */
public static GrpcReflectionAccessDecision deny(String reason) {
return new GrpcReflectionAccessDecision(false, reason);
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.grpc.admin;
import dev.caskeleton.grpc.security.GrpcTlsProfile;
/**
* Whether server reflection is exposed, and to whom.
*
* <p>Disabled in production by default. Reflection publishes the complete schema — every service,
* method, message and field — to anyone who can open a connection, which is a map of the attack
* surface handed out before authentication. It is genuinely useful in development, and the cost of
* leaving it on afterwards is that nobody notices it is on.
*/
public enum GrpcReflectionMode {
/** Anyone who can reach the port may enumerate the schema. */
ENABLED,
/** Only a caller on the admin network holding an admin role. */
ADMIN_ONLY,
/** Not registered at all. */
DISABLED;
/** The default for an environment. */
public static GrpcReflectionMode defaultFor(GrpcTlsProfile.Environment environment) {
if (environment == null) {
throw new IllegalArgumentException("an environment is required");
}
return switch (environment) {
case LOCAL, TEST -> ENABLED;
case DEV -> ADMIN_ONLY;
case STAGE, PROD -> DISABLED;
};
}
}
@@ -0,0 +1,74 @@
package dev.caskeleton.grpc.admin;
import dev.caskeleton.grpc.security.GrpcTlsProfile;
import java.util.Set;
/**
* The reflection exposure decision, and the two conditions that gate it.
*
* <p>Network and role are both required in {@link GrpcReflectionMode#ADMIN_ONLY}, and requiring
* both is the point: a role check alone lets an admin credential leaked to the public network
* enumerate the schema, and a network check alone lets anyone who reaches the admin network do it.
*
* <p>Reflection visibility is separate from method authorization. A method that reflection reveals
* is not thereby callable, and a method reflection hides is not thereby protected. Conflating the
* two produces a schema treated as a secret and an authorization check nobody wrote.
*/
public record GrpcReflectionPolicy(
GrpcReflectionMode mode, Set<String> adminNetworks, Set<String> adminRoles) {
/** Refuses an admin-only policy with nothing to check. */
public GrpcReflectionPolicy {
if (mode == null) {
throw new IllegalArgumentException("a reflection policy names its mode");
}
if (adminNetworks == null || adminRoles == null) {
throw new IllegalArgumentException("a reflection policy states both gates, even if empty");
}
adminNetworks = Set.copyOf(adminNetworks);
adminRoles = Set.copyOf(adminRoles);
if (mode == GrpcReflectionMode.ADMIN_ONLY
&& (adminNetworks.isEmpty() || adminRoles.isEmpty())) {
throw new IllegalArgumentException(
"ADMIN_ONLY requires both an admin network and an admin role; a role alone lets a leaked "
+ "credential enumerate the schema from anywhere, and a network alone lets anyone who "
+ "reaches it do the same");
}
}
/** The default policy for an environment. */
public static GrpcReflectionPolicy defaultFor(GrpcTlsProfile.Environment environment) {
GrpcReflectionMode mode = GrpcReflectionMode.defaultFor(environment);
return mode == GrpcReflectionMode.ADMIN_ONLY
? new GrpcReflectionPolicy(mode, Set.of("admin"), Set.of("ROLE_PLATFORM_ADMIN"))
: new GrpcReflectionPolicy(mode, Set.of(), Set.of());
}
/** Whether this caller may enumerate the schema. */
public GrpcReflectionAccessDecision decide(String callerNetwork, Set<String> callerRoles) {
return switch (mode) {
case DISABLED ->
GrpcReflectionAccessDecision.deny(
"reflection is disabled here; the schema is not published to callers");
case ENABLED ->
GrpcReflectionAccessDecision.allow("reflection is enabled in this environment");
case ADMIN_ONLY -> decideAdminOnly(callerNetwork, callerRoles);
};
}
private GrpcReflectionAccessDecision decideAdminOnly(
String callerNetwork, Set<String> callerRoles) {
if (callerNetwork == null || !adminNetworks.contains(callerNetwork)) {
return GrpcReflectionAccessDecision.deny("the caller is not on an admin network");
}
if (callerRoles == null || callerRoles.stream().noneMatch(adminRoles::contains)) {
return GrpcReflectionAccessDecision.deny("the caller holds no admin role");
}
return GrpcReflectionAccessDecision.allow("admin network and admin role both satisfied");
}
/** Whether the reflection service should be registered on the server at all. */
public boolean registerService() {
return mode != GrpcReflectionMode.DISABLED;
}
}
@@ -0,0 +1,154 @@
package dev.caskeleton.grpc.admin;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* Per-service health, plus the global answer derived from it.
*
* <p>Starts every registered service at {@link GrpcHealthState#UNKNOWN} rather than at {@code
* SERVING}. A registry that starts optimistic reports ready during startup, receives traffic before
* the first dependency check has run, and fails the requests that arrive in that window — the
* window being exactly the moment a rollout is shifting traffic onto the instance.
*/
public final class GrpcServiceHealthRegistry {
/** The global health key, matching the standard gRPC health service convention. */
public static final String GLOBAL_SERVICE = "";
private final ConcurrentMap<String, GrpcHealthState> states = new ConcurrentHashMap<>();
private final ConcurrentMap<String, Boolean> dependencyHealth = new ConcurrentHashMap<>();
private final GrpcHealthPolicy policy;
private volatile boolean draining;
/** Binds a registry to its health policy. */
public GrpcServiceHealthRegistry(GrpcHealthPolicy policy) {
if (policy == null) {
throw new IllegalArgumentException("a health registry needs a policy");
}
this.policy = policy;
states.put(GLOBAL_SERVICE, GrpcHealthState.UNKNOWN);
}
/** Registers a service, unknown until something says otherwise. */
public void register(String serviceName) {
requireServiceName(serviceName);
states.putIfAbsent(serviceName, GrpcHealthState.UNKNOWN);
}
/**
* Records that a service is serving.
*
* <p>Ignored while draining: a service that reports itself healthy after the drain has started
* would be routed traffic the instance has already promised not to take.
*/
public void markServing(String serviceName) {
requireServiceName(serviceName);
if (draining) {
return;
}
states.put(serviceName, GrpcHealthState.SERVING);
recomputeGlobal();
}
/** Records that a service is not serving. */
public void markNotServing(String serviceName) {
requireServiceName(serviceName);
if (draining) {
return;
}
states.put(serviceName, GrpcHealthState.NOT_SERVING);
recomputeGlobal();
}
/** Records a dependency's health, which only matters when the policy says it does. */
public void recordDependencyHealth(String dependencyName, boolean healthy) {
if (dependencyName == null || dependencyName.isBlank()) {
throw new IllegalArgumentException("a dependency needs a name");
}
dependencyHealth.put(dependencyName, healthy);
if (!draining) {
recomputeGlobal();
}
}
/**
* Flips everything to draining.
*
* <p>Called first in a shutdown, before admission stops, so that a load balancer has a chance to
* stop sending before the server starts refusing.
*/
public void beginDraining() {
draining = true;
states.replaceAll((service, state) -> GrpcHealthState.DRAINING);
}
/** The state of one service. */
public GrpcHealthState stateOf(String serviceName) {
requireServiceName(serviceName);
return states.getOrDefault(serviceName, GrpcHealthState.SERVICE_UNKNOWN);
}
/** The global state. */
public GrpcHealthState globalState() {
return states.getOrDefault(GLOBAL_SERVICE, GrpcHealthState.UNKNOWN);
}
/** Whether this instance should be routed new work. */
public boolean ready() {
return globalState().acceptsNewWork();
}
/** Every registered service and its state. */
public Map<String, GrpcHealthState> snapshot() {
return Map.copyOf(states);
}
/** The critical dependencies currently reported unhealthy. */
public Set<String> unhealthyCriticalDependencies() {
Set<String> unhealthy = new LinkedHashSet<>();
dependencyHealth.forEach(
(dependency, healthy) -> {
if (!healthy && policy.affectsHealth(dependency)) {
unhealthy.add(dependency);
}
});
return Set.copyOf(unhealthy);
}
private void recomputeGlobal() {
if (!unhealthyCriticalDependencies().isEmpty()) {
states.put(GLOBAL_SERVICE, GrpcHealthState.NOT_SERVING);
return;
}
boolean anyServing =
states.entrySet().stream()
.anyMatch(
entry ->
!GLOBAL_SERVICE.equals(entry.getKey())
&& entry.getValue() == GrpcHealthState.SERVING);
boolean anyNotServing =
states.entrySet().stream()
.anyMatch(
entry ->
!GLOBAL_SERVICE.equals(entry.getKey())
&& entry.getValue() == GrpcHealthState.NOT_SERVING);
if (anyNotServing) {
states.put(GLOBAL_SERVICE, GrpcHealthState.NOT_SERVING);
} else if (anyServing) {
states.put(GLOBAL_SERVICE, GrpcHealthState.SERVING);
} else {
states.put(GLOBAL_SERVICE, GrpcHealthState.UNKNOWN);
}
}
private static void requireServiceName(String serviceName) {
if (serviceName == null) {
throw new IllegalArgumentException(
"a service name is required; use GLOBAL_SERVICE for global");
}
}
}
@@ -0,0 +1,144 @@
package dev.caskeleton.grpc.admin;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.server.GrpcAdmissionController;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcDrainCoordinatorTest {
private static final Instant START = Instant.parse("2026-08-30T10:00:00Z");
private static final String DOCUMENTS = "hyeonworks.document.v1.DocumentService";
private final AtomicInteger unaryCalls = new AtomicInteger();
private final AtomicInteger streams = new AtomicInteger();
private final GrpcServiceHealthRegistry health =
new GrpcServiceHealthRegistry(GrpcHealthPolicy.standalone());
private GrpcDrainCoordinator coordinator(GrpcDrainPolicy policy) {
health.register(DOCUMENTS);
health.markServing(DOCUMENTS);
return new GrpcDrainCoordinator(
health, new GrpcAdmissionController(8, 8), policy, unaryCalls::get, streams::get);
}
@Test
@DisplayName("readiness flips before anything is refused")
void readinessFlipsBeforeRefusal() {
GrpcDrainCoordinator coordinator = coordinator(GrpcDrainPolicy.stable());
coordinator.beginDrain(START);
assertThat(health.ready()).isFalse();
assertThat(health.stateOf(DOCUMENTS)).isEqualTo(GrpcHealthState.DRAINING);
assertThat(coordinator.admittingNewCalls()).isTrue();
}
@Test
@DisplayName("refusing before the drain has begun is an error")
void refusingBeforeDrainingIsAnError() {
GrpcDrainCoordinator coordinator = coordinator(GrpcDrainPolicy.stable());
assertThatThrownBy(coordinator::rejectNewAdmission)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("routing is still sending");
}
@Test
@DisplayName("the unary drain finishes when nothing is in flight")
void theUnaryDrainFinishesWhenIdle() {
GrpcDrainCoordinator coordinator = coordinator(GrpcDrainPolicy.stable());
coordinator.beginDrain(START);
coordinator.rejectNewAdmission();
unaryCalls.set(2);
assertThat(coordinator.unaryDrainComplete(START.plusSeconds(1))).isFalse();
unaryCalls.set(0);
assertThat(coordinator.unaryDrainComplete(START.plusSeconds(2))).isTrue();
}
@Test
@DisplayName("the unary drain gives up when its budget elapses")
void theUnaryDrainIsBounded() {
GrpcDrainCoordinator coordinator = coordinator(GrpcDrainPolicy.stable());
coordinator.beginDrain(START);
unaryCalls.set(3);
assertThat(coordinator.unaryDrainComplete(START.plusSeconds(14))).isFalse();
assertThat(coordinator.unaryDrainComplete(START.plusSeconds(15))).isTrue();
}
@Test
@DisplayName("streams are signalled rather than waited for, and the count is recorded")
void streamsAreSignalledNotAwaited() {
GrpcDrainCoordinator coordinator = coordinator(GrpcDrainPolicy.stable());
coordinator.beginDrain(START);
streams.set(5);
coordinator.signalStreams();
GrpcDrainResult result = coordinator.forceCancel(START.plusSeconds(20));
assertThat(result.signalledStreams()).isEqualTo(5);
assertThat(result.cancelledStreams()).isEqualTo(5);
assertThat(result.forcedAnything()).isTrue();
}
@Test
@DisplayName("a full drain runs every phase in order and reports what it achieved")
void aFullDrainRunsEveryPhase() {
GrpcDrainCoordinator coordinator = coordinator(GrpcDrainPolicy.stable());
unaryCalls.set(4);
streams.set(2);
coordinator.beginDrain(START);
coordinator.rejectNewAdmission();
unaryCalls.set(0);
coordinator.recordCompletedUnaryCalls(4);
assertThat(coordinator.unaryDrainComplete(START.plusSeconds(3))).isTrue();
coordinator.signalStreams();
streams.set(0);
assertThat(coordinator.streamSignalComplete(START.plusSeconds(4))).isTrue();
GrpcDrainResult result = coordinator.forceCancel(START.plusSeconds(5));
assertThat(result.phasesRun())
.containsExactly(
GrpcDrainPhase.READINESS_FALSE,
GrpcDrainPhase.HEALTH_DRAINING,
GrpcDrainPhase.REJECT_NEW_ADMISSION,
GrpcDrainPhase.DRAIN_UNARY,
GrpcDrainPhase.SIGNAL_STREAMS,
GrpcDrainPhase.FORCE_CANCEL);
assertThat(result.complete()).isTrue();
assertThat(result.completedUnaryCalls()).isEqualTo(4);
assertThat(result.cancelledUnaryCalls()).isZero();
assertThat(result.forcedAnything()).isFalse();
assertThat(result.elapsed()).isEqualTo(Duration.ofSeconds(5));
}
@Test
@DisplayName("beginning a drain twice does not restart the clock")
void beginningTwiceIsIdempotent() {
GrpcDrainCoordinator coordinator = coordinator(GrpcDrainPolicy.stable());
coordinator.beginDrain(START);
coordinator.beginDrain(START.plusSeconds(30));
unaryCalls.set(1);
assertThat(coordinator.unaryDrainComplete(START.plusSeconds(15))).isTrue();
}
@Test
@DisplayName("drain budgets are separate and bounded in total")
void drainBudgetsAreSeparateAndBounded() {
GrpcDrainPolicy policy = GrpcDrainPolicy.stable();
assertThat(policy.totalBudget()).isEqualTo(Duration.ofSeconds(25));
assertThatThrownBy(() -> new GrpcDrainPolicy(Duration.ZERO, Duration.ZERO, Duration.ZERO))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("cancels every in-flight call");
}
}
@@ -0,0 +1,172 @@
package dev.caskeleton.grpc.admin;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.security.GrpcTlsProfile;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcPlatformSnapshotServiceTest {
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
private static final String DOCUMENTS = "hyeonworks.document.v1.DocumentService";
private final GrpcServiceHealthRegistry health =
new GrpcServiceHealthRegistry(GrpcHealthPolicy.standalone());
private GrpcPlatformSnapshotService service() {
health.register(DOCUMENTS);
health.markServing(DOCUMENTS);
return new GrpcPlatformSnapshotService(
GrpcAdminExposurePolicy.standard(),
health,
GrpcReflectionPolicy.defaultFor(GrpcTlsProfile.Environment.PROD));
}
private Optional<GrpcPlatformSnapshot> capture(
String network, Set<String> roles, String schemaVersion, Map<String, String> channelHashes) {
return service()
.capture(
network,
roles,
"snapshot-1",
schemaVersion,
"sha256:policy-1",
List.of(DOCUMENTS),
channelHashes,
Map.of("documents-read", "dns/pick_first"),
Map.of("documents-read", "GRPC_PLATFORM"),
NOW);
}
@Test
@DisplayName("a snapshot needs both an admin network and an admin role")
void bothGatesAreRequired() {
assertThat(
capture(
"admin",
Set.of("ROLE_PLATFORM_ADMIN"),
"1.4.0",
Map.of("documents-read", "sha256:channel-1")))
.isPresent();
assertThat(
capture(
"public",
Set.of("ROLE_PLATFORM_ADMIN"),
"1.4.0",
Map.of("documents-read", "sha256:channel-1")))
.isEmpty();
assertThat(
capture(
"admin",
Set.of("ROLE_USER"),
"1.4.0",
Map.of("documents-read", "sha256:channel-1")))
.isEmpty();
}
@Test
@DisplayName("an admin policy with only one gate is refused")
void oneGateIsNotAnAdminSurface() {
assertThatThrownBy(() -> new GrpcAdminExposurePolicy(Set.of("admin"), Set.of()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("both a network and a role gate");
}
@Test
@DisplayName("a snapshot carries hashes and health, and never a credential field")
void aSnapshotCarriesNoSecrets() {
GrpcPlatformSnapshot snapshot =
capture(
"admin",
Set.of("ROLE_PLATFORM_ADMIN"),
"1.4.0",
Map.of("documents-read", "sha256:channel-1"))
.orElseThrow();
assertThat(snapshot.registeredServices()).containsExactly(DOCUMENTS);
assertThat(snapshot.serviceHealth()).containsEntry(DOCUMENTS, GrpcHealthState.SERVING);
assertThat(snapshot.reflectionMode()).isEqualTo(GrpcReflectionMode.DISABLED);
assertThat(snapshot.draining()).isFalse();
}
@Test
@DisplayName("a snapshot containing a credential-named field is refused outright")
void aCredentialFieldIsRefused() {
assertThatThrownBy(
() ->
capture(
"admin",
Set.of("ROLE_PLATFORM_ADMIN"),
"1.4.0",
Map.of("documents-read-client-secret", "value")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("hashes and names only");
assertThat(GrpcAdminExposurePolicy.secretField("target-password")).isTrue();
assertThat(GrpcAdminExposurePolicy.secretField("private_key_ref")).isTrue();
assertThat(GrpcAdminExposurePolicy.secretField("documents-read")).isFalse();
}
@Test
@DisplayName("drift against the release manifest is reported field by field")
void driftAgainstReleaseIsReported() {
GrpcPlatformSnapshot running =
capture(
"admin",
Set.of("ROLE_PLATFORM_ADMIN"),
"1.5.0",
Map.of(
"documents-read", "sha256:channel-2", "documents-stream", "sha256:channel-9"))
.orElseThrow();
GrpcPlatformSnapshot released =
capture(
"admin",
Set.of("ROLE_PLATFORM_ADMIN"),
"1.4.0",
Map.of("documents-read", "sha256:channel-1"))
.orElseThrow();
List<String> drift = GrpcPlatformSnapshotService.driftAgainstRelease(running, released);
assertThat(drift)
.anySatisfy(entry -> assertThat(entry).contains("schema version is 1.5.0"))
.anySatisfy(entry -> assertThat(entry).contains("documents-read"))
.anySatisfy(entry -> assertThat(entry).contains("documents-stream"));
}
@Test
@DisplayName("an instance running the released configuration reports no drift")
void noDriftWhenRunningTheRelease() {
GrpcPlatformSnapshot snapshot =
capture(
"admin",
Set.of("ROLE_PLATFORM_ADMIN"),
"1.4.0",
Map.of("documents-read", "sha256:channel-1"))
.orElseThrow();
assertThat(GrpcPlatformSnapshotService.driftAgainstRelease(snapshot, snapshot)).isEmpty();
assertThat(snapshot.sameConfigurationAs(snapshot)).isTrue();
}
@Test
@DisplayName("a snapshot must be versioned and timestamped")
void aSnapshotIsVersionedAndTimestamped() {
GrpcPlatformSnapshot snapshot =
capture(
"admin",
Set.of("ROLE_PLATFORM_ADMIN"),
"1.4.0",
Map.of("documents-read", "sha256:channel-1"))
.orElseThrow();
assertThat(snapshot.snapshotVersion()).isEqualTo("snapshot-1");
assertThat(snapshot.capturedAt()).isEqualTo(NOW);
}
}
@@ -0,0 +1,83 @@
package dev.caskeleton.grpc.admin;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.security.GrpcTlsProfile;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcReflectionPolicyTest {
@Test
@DisplayName("reflection is off in production and on locally by default")
void defaultsFollowTheEnvironment() {
assertThat(GrpcReflectionMode.defaultFor(GrpcTlsProfile.Environment.PROD))
.isEqualTo(GrpcReflectionMode.DISABLED);
assertThat(GrpcReflectionMode.defaultFor(GrpcTlsProfile.Environment.STAGE))
.isEqualTo(GrpcReflectionMode.DISABLED);
assertThat(GrpcReflectionMode.defaultFor(GrpcTlsProfile.Environment.DEV))
.isEqualTo(GrpcReflectionMode.ADMIN_ONLY);
assertThat(GrpcReflectionMode.defaultFor(GrpcTlsProfile.Environment.LOCAL))
.isEqualTo(GrpcReflectionMode.ENABLED);
assertThat(GrpcReflectionMode.defaultFor(GrpcTlsProfile.Environment.TEST))
.isEqualTo(GrpcReflectionMode.ENABLED);
}
@Test
@DisplayName("admin-only reflection requires both a network and a role")
void adminOnlyNeedsBothGates() {
GrpcReflectionPolicy policy = GrpcReflectionPolicy.defaultFor(GrpcTlsProfile.Environment.DEV);
assertThat(policy.decide("admin", Set.of("ROLE_PLATFORM_ADMIN")).allowed()).isTrue();
assertThat(policy.decide("public", Set.of("ROLE_PLATFORM_ADMIN")).allowed()).isFalse();
assertThat(policy.decide("admin", Set.of("ROLE_USER")).allowed()).isFalse();
assertThat(policy.decide(null, Set.of("ROLE_PLATFORM_ADMIN")).allowed()).isFalse();
assertThat(policy.decide("admin", null).allowed()).isFalse();
}
@Test
@DisplayName("an admin-only policy with nothing to check is refused at construction")
void anAdminOnlyPolicyNeedsSomethingToCheck() {
assertThatThrownBy(
() ->
new GrpcReflectionPolicy(GrpcReflectionMode.ADMIN_ONLY, Set.of("admin"), Set.of()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("leaked credential");
assertThatThrownBy(
() ->
new GrpcReflectionPolicy(
GrpcReflectionMode.ADMIN_ONLY, Set.of(), Set.of("ROLE_PLATFORM_ADMIN")))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("a disabled policy registers no service and explains its refusal")
void disabledRegistersNothing() {
GrpcReflectionPolicy policy = GrpcReflectionPolicy.defaultFor(GrpcTlsProfile.Environment.PROD);
assertThat(policy.registerService()).isFalse();
GrpcReflectionAccessDecision decision = policy.decide("admin", Set.of("ROLE_PLATFORM_ADMIN"));
assertThat(decision.allowed()).isFalse();
assertThat(decision.reason()).contains("not published to callers");
}
@Test
@DisplayName("an enabled policy registers the service and allows any reachable caller")
void enabledRegistersAndAllows() {
GrpcReflectionPolicy policy = GrpcReflectionPolicy.defaultFor(GrpcTlsProfile.Environment.LOCAL);
assertThat(policy.registerService()).isTrue();
assertThat(policy.decide("anything", Set.of()).allowed()).isTrue();
}
@Test
@DisplayName("a decision always explains itself, allowed or not")
void everyDecisionExplainsItself() {
assertThatThrownBy(() -> GrpcReflectionAccessDecision.allow(" "))
.isInstanceOf(IllegalArgumentException.class);
assertThat(GrpcReflectionAccessDecision.deny("no admin role").reason())
.isEqualTo("no admin role");
}
}
@@ -0,0 +1,91 @@
package dev.caskeleton.grpc.admin;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcServiceHealthRegistryTest {
private static final String DOCUMENTS = "hyeonworks.document.v1.DocumentService";
@Test
@DisplayName("an instance is not ready before its first check completes")
void readinessStartsFalse() {
GrpcServiceHealthRegistry registry =
new GrpcServiceHealthRegistry(GrpcHealthPolicy.standalone());
registry.register(DOCUMENTS);
assertThat(registry.globalState()).isEqualTo(GrpcHealthState.UNKNOWN);
assertThat(registry.ready()).isFalse();
assertThat(registry.stateOf(DOCUMENTS)).isEqualTo(GrpcHealthState.UNKNOWN);
}
@Test
@DisplayName("a policy that reports ready before the first check is refused")
void optimisticReadinessIsRefused() {
assertThatThrownBy(() -> new GrpcHealthPolicy(Set.of(), true))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("before it can serve it");
}
@Test
@DisplayName("an unregistered service reports SERVICE_UNKNOWN")
void anUnregisteredServiceIsUnknown() {
GrpcServiceHealthRegistry registry =
new GrpcServiceHealthRegistry(GrpcHealthPolicy.standalone());
assertThat(registry.stateOf("hyeonworks.other.v1.OtherService"))
.isEqualTo(GrpcHealthState.SERVICE_UNKNOWN);
}
@Test
@DisplayName("a serving service makes the instance ready")
void aServingServiceMakesTheInstanceReady() {
GrpcServiceHealthRegistry registry =
new GrpcServiceHealthRegistry(GrpcHealthPolicy.standalone());
registry.register(DOCUMENTS);
registry.markServing(DOCUMENTS);
assertThat(registry.globalState()).isEqualTo(GrpcHealthState.SERVING);
assertThat(registry.ready()).isTrue();
}
@Test
@DisplayName("only a correctness-critical dependency affects health")
void onlyCriticalDependenciesAffectHealth() {
GrpcServiceHealthRegistry registry =
new GrpcServiceHealthRegistry(GrpcHealthPolicy.dependingOn(Set.of("documents-db")));
registry.register(DOCUMENTS);
registry.markServing(DOCUMENTS);
registry.recordDependencyHealth("analytics-sink", false);
assertThat(registry.ready()).isTrue();
registry.recordDependencyHealth("documents-db", false);
assertThat(registry.ready()).isFalse();
assertThat(registry.unhealthyCriticalDependencies()).containsExactly("documents-db");
}
@Test
@DisplayName("draining is distinguishable from failing, and is not undone by a later check")
void drainingIsDistinguishableAndSticky() {
GrpcServiceHealthRegistry registry =
new GrpcServiceHealthRegistry(GrpcHealthPolicy.standalone());
registry.register(DOCUMENTS);
registry.markServing(DOCUMENTS);
registry.beginDraining();
assertThat(registry.stateOf(DOCUMENTS)).isEqualTo(GrpcHealthState.DRAINING);
assertThat(registry.ready()).isFalse();
assertThat(GrpcHealthState.DRAINING.standardEquivalent())
.isEqualTo(GrpcHealthState.NOT_SERVING);
registry.markServing(DOCUMENTS);
assertThat(registry.stateOf(DOCUMENTS)).isEqualTo(GrpcHealthState.DRAINING);
}
}
+17
View File
@@ -0,0 +1,17 @@
apply plugin: 'java-library'
// Client runtime: named channel profiles, channel runtime generations with drain, the typed stub
// factory that refuses to hand a raw Channel to application code, and client metadata/credentials.
dependencyManagement {
imports {
mavenBom "io.grpc:grpc-bom:${grpcVersion}"
}
}
dependencies {
api project(':grpc:grpc-core-api')
api project(':grpc:grpc-policy')
api "io.grpc:grpc-api:${grpcVersion}"
api "io.grpc:grpc-stub:${grpcVersion}"
}
+91
View File
@@ -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,32 @@
package dev.caskeleton.grpc.client;
import java.time.Instant;
import java.util.Optional;
/**
* Supplies the credential for one call, at the moment of the call.
*
* <p>Per attempt, not per stub. A token captured when the stub was built is the token that was
* valid at startup: it expires while the process runs, a retry re-sends the expired one, and the
* failure looks like an authorization problem rather than a lifetime one.
*
* <p>The value returned is the header value only. Nothing here hands out the underlying key or
* refresh token, so a caller cannot cache one.
*/
@FunctionalInterface
public interface GrpcCallCredentialProvider {
/**
* The credential to send, if the caller has one.
*
* @param at the moment of the call, so an expiring credential can be refreshed or refused
* @return empty when the call is legitimately anonymous. An expired credential must be refreshed
* or reported, never returned.
*/
Optional<String> credentialFor(Instant at);
/** A provider that never supplies a credential, for anonymous methods. */
static GrpcCallCredentialProvider anonymous() {
return at -> Optional.empty();
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.grpc.client;
import java.time.Duration;
/**
* How long a superseded channel generation keeps serving before its calls are cancelled.
*
* <p>Unary and stream drains are separated because their honest budgets differ by orders of
* magnitude. A unary call finishes inside its deadline; a subscription may legitimately have been
* open for an hour and will not finish at all, so its drain is a signal to reconnect rather than a
* wait.
*/
public record GrpcChannelDrainPolicy(
Duration unaryDrain, Duration streamDrain, boolean forceCancel) {
/** Refuses a drain policy that would either hang or cut instantly. */
public GrpcChannelDrainPolicy {
if (unaryDrain == null || streamDrain == null) {
throw new IllegalArgumentException("a drain policy needs both budgets");
}
if (unaryDrain.isNegative() || streamDrain.isNegative()) {
throw new IllegalArgumentException("drain budgets must not be negative");
}
if (!forceCancel && (unaryDrain.isZero() || streamDrain.isZero())) {
throw new IllegalArgumentException(
"a drain that neither waits nor force-cancels leaves the old generation alive forever");
}
}
/** The Stable default: wait briefly for unary calls, signal streams, then cancel. */
public static GrpcChannelDrainPolicy stable() {
return new GrpcChannelDrainPolicy(Duration.ofSeconds(10), Duration.ofSeconds(30), true);
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.grpc.client;
import dev.caskeleton.grpc.core.GrpcChannelProfileName;
import java.time.Instant;
/**
* One version of a channel's configuration, numbered so that a swap is observable.
*
* <p>The number is what makes a rotation distinguishable from a restart. Without it, "the channel
* was reconfigured" and "some calls are still on the old configuration" are the same state, and the
* drain that separates them has nothing to key on.
*/
public record GrpcChannelGeneration(
GrpcChannelProfileName profileName,
long generation,
String configurationHash,
Instant createdAt) {
/** Requires a positive generation and a configuration digest. */
public GrpcChannelGeneration {
if (profileName == null) {
throw new IllegalArgumentException("a channel generation names its profile");
}
if (generation < 1) {
throw new IllegalArgumentException("channel generations are 1-based; got " + generation);
}
if (configurationHash == null || configurationHash.isBlank()) {
throw new IllegalArgumentException(
"a channel generation carries a hash of the configuration it was built from");
}
if (createdAt == null) {
throw new IllegalArgumentException("a channel generation needs a creation moment");
}
}
/** Whether {@code candidate} would legally succeed this generation. */
public boolean supersededBy(GrpcChannelGeneration candidate) {
return candidate != null
&& candidate.profileName().equals(profileName)
&& candidate.generation() > generation;
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.grpc.client;
import dev.caskeleton.grpc.security.GrpcAuthenticationProfile;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Checks a set of channel profiles for the mistakes that do not announce themselves.
*
* <p>Two in particular. Round-robin over a target that resolves to one address is described in
* dashboards as client-side load balancing and is not; and two profiles pointing at the same target
* with the same settings are one channel with two names, which is the shape that appears when
* somebody wanted a different SLO and copied the profile instead.
*/
public final class GrpcChannelProfileValidator {
private GrpcChannelProfileValidator() {}
/**
* Every problem with {@code profiles}.
*
* @param resolvedAddressCounts how many addresses each profile's target actually resolves to,
* where that is known. A profile absent from the map is not checked for the round-robin
* mistake, because guessing would produce a false failure on a name nobody can resolve at
* startup.
*/
public static List<String> violations(
List<GrpcNamedChannelProfile> profiles, Map<String, Integer> resolvedAddressCounts) {
if (profiles == null || resolvedAddressCounts == null) {
throw new IllegalArgumentException("validation needs the profiles and the resolved counts");
}
List<String> violations = new ArrayList<>();
Map<String, String> seenNames = new LinkedHashMap<>();
for (GrpcNamedChannelProfile profile : profiles) {
String profileName = profile.name().value();
String previous = seenNames.putIfAbsent(profileName, profile.target().toString());
if (previous != null) {
violations.add(
"channel profile '"
+ profileName
+ "' is declared twice, for '"
+ previous
+ "' and '"
+ profile.target()
+ "'");
}
Integer addresses = resolvedAddressCounts.get(profileName);
if (addresses != null
&& profile.loadBalancingPolicy().requiresMultipleAddresses()
&& addresses <= 1) {
violations.add(
"channel profile '"
+ profileName
+ "' uses "
+ profile.loadBalancingPolicy()
+ " over a target that resolves to "
+ addresses
+ " address; every request goes to the same endpoint, so calling this "
+ "client-side load balancing describes spreading that is not happening");
}
}
return List.copyOf(violations);
}
/**
* Whether {@code authentication} can be carried by {@code profile}.
*
* @throws IllegalStateException when the transport cannot carry the credential safely
*/
public static void requireCompatibleAuthentication(
GrpcNamedChannelProfile profile, GrpcAuthenticationProfile authentication) {
if (profile == null || authentication == null) {
throw new IllegalArgumentException(
"a compatibility check needs a profile and an authentication");
}
authentication.requireCompatible(profile.tls());
}
}
@@ -0,0 +1,96 @@
package dev.caskeleton.grpc.client;
import java.util.concurrent.atomic.AtomicInteger;
/**
* A live channel generation and the calls currently on it.
*
* <p>Counts in-flight unary calls and open streams separately, because a drain treats them
* differently: unary calls are waited for, streams are signalled. A single counter would make the
* drain either cut a stream that could have finished or wait an hour for one that never will.
*
* <p>The underlying {@code ManagedChannel} is deliberately not exposed. Handing it out is how
* application code ends up building a stub with no policy attached.
*/
public final class GrpcChannelRuntime {
private final GrpcChannelGeneration generation;
private final AtomicInteger inFlightUnaryCalls = new AtomicInteger();
private final AtomicInteger openStreams = new AtomicInteger();
private volatile boolean draining;
/** Wraps one generation. */
public GrpcChannelRuntime(GrpcChannelGeneration generation) {
if (generation == null) {
throw new IllegalArgumentException("a channel runtime needs its generation");
}
this.generation = generation;
}
/** The generation this runtime serves. */
public GrpcChannelGeneration generation() {
return generation;
}
/**
* Registers a starting unary call.
*
* @return false when the runtime is draining, in which case the caller uses the current
* generation
*/
public boolean startUnaryCall() {
if (draining) {
return false;
}
inFlightUnaryCalls.incrementAndGet();
return true;
}
/** Registers a finished unary call. */
public void finishUnaryCall() {
if (inFlightUnaryCalls.get() > 0) {
inFlightUnaryCalls.decrementAndGet();
}
}
/** Registers an opening stream. */
public boolean openStream() {
if (draining) {
return false;
}
openStreams.incrementAndGet();
return true;
}
/** Registers a closed stream. */
public void closeStream() {
if (openStreams.get() > 0) {
openStreams.decrementAndGet();
}
}
/** Stops admitting new work onto this generation. */
public void beginDrain() {
draining = true;
}
/** Whether this runtime is draining. */
public boolean draining() {
return draining;
}
/** How many unary calls are still running. */
public int inFlightUnaryCalls() {
return inFlightUnaryCalls.get();
}
/** How many streams are still open. */
public int openStreams() {
return openStreams.get();
}
/** Whether everything on this generation has finished. */
public boolean quiescent() {
return inFlightUnaryCalls.get() == 0 && openStreams.get() == 0;
}
}
@@ -0,0 +1,137 @@
package dev.caskeleton.grpc.client;
import dev.caskeleton.grpc.core.GrpcChannelProfileName;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicReference;
/**
* Holds one live runtime per channel profile and swaps it without dropping work.
*
* <p>Two properties, and the first is the boring one that matters most: a channel is created once
* and reused. Creating one per request is a mistake that works — every call succeeds — while
* spending a TCP handshake, a TLS handshake and an HTTP/2 setup on each one, and it is usually
* found by a connection count rather than by a failure.
*
* <p>The second is prepare-then-swap. The new runtime exists before the pointer moves, so no call
* ever finds nothing there; the old one drains rather than being closed under its in-flight work.
*/
public final class GrpcChannelRuntimeRegistry {
private final ConcurrentMap<GrpcChannelProfileName, AtomicReference<GrpcChannelRuntime>> current =
new ConcurrentHashMap<>();
private final ConcurrentMap<GrpcChannelProfileName, List<GrpcChannelRuntime>> draining =
new ConcurrentHashMap<>();
private final GrpcChannelDrainPolicy drainPolicy;
/** Binds a registry to the drain policy its swaps use. */
public GrpcChannelRuntimeRegistry(GrpcChannelDrainPolicy drainPolicy) {
if (drainPolicy == null) {
throw new IllegalArgumentException("a channel registry needs a drain policy");
}
this.drainPolicy = drainPolicy;
}
/** Installs the first generation for a profile. */
public GrpcChannelRuntime install(GrpcChannelGeneration generation) {
if (generation == null) {
throw new IllegalArgumentException("a generation is required");
}
GrpcChannelRuntime runtime = new GrpcChannelRuntime(generation);
AtomicReference<GrpcChannelRuntime> holder =
current.computeIfAbsent(generation.profileName(), key -> new AtomicReference<>());
if (!holder.compareAndSet(null, runtime)) {
throw new IllegalStateException(
"channel profile '"
+ generation.profileName().value()
+ "' already has a runtime; use rotate()");
}
return runtime;
}
/**
* The runtime new calls should use.
*
* @throws IllegalStateException when nothing is installed, because returning empty would make
* "not configured" indistinguishable from "configured and unreachable"
*/
public GrpcChannelRuntime require(GrpcChannelProfileName profileName) {
AtomicReference<GrpcChannelRuntime> holder = current.get(profileName);
GrpcChannelRuntime runtime = holder == null ? null : holder.get();
if (runtime == null) {
throw new IllegalStateException(
"no channel runtime is installed for profile '" + profileName.value() + "'");
}
return runtime;
}
/** The runtime for a profile, if one is installed. */
public Optional<GrpcChannelRuntime> find(GrpcChannelProfileName profileName) {
AtomicReference<GrpcChannelRuntime> holder = current.get(profileName);
return Optional.ofNullable(holder == null ? null : holder.get());
}
/**
* Prepares the next generation, swaps it in, and starts draining the previous one.
*
* @throws IllegalArgumentException when {@code next} does not supersede what is installed
*/
public GrpcChannelRuntime rotate(GrpcChannelGeneration next, Instant now) {
if (next == null || now == null) {
throw new IllegalArgumentException("a rotation needs a generation and a moment");
}
AtomicReference<GrpcChannelRuntime> holder = current.get(next.profileName());
if (holder == null || holder.get() == null) {
return install(next);
}
GrpcChannelRuntime previous = holder.get();
if (!previous.generation().supersededBy(next)) {
throw new IllegalArgumentException(
"generation "
+ next.generation()
+ " does not supersede the installed generation "
+ previous.generation().generation()
+ " for profile '"
+ next.profileName().value()
+ "'");
}
GrpcChannelRuntime replacement = new GrpcChannelRuntime(next);
holder.set(replacement);
previous.beginDrain();
draining
.computeIfAbsent(
next.profileName(), key -> java.util.Collections.synchronizedList(new ArrayList<>()))
.add(previous);
return replacement;
}
/** The generations still draining for a profile. */
public List<GrpcChannelRuntime> draining(GrpcChannelProfileName profileName) {
return List.copyOf(draining.getOrDefault(profileName, List.of()));
}
/**
* Forgets every draining runtime that has gone quiet.
*
* @return how many were retired
*/
public int retireQuiescent(GrpcChannelProfileName profileName) {
List<GrpcChannelRuntime> runtimes = draining.get(profileName);
if (runtimes == null) {
return 0;
}
List<GrpcChannelRuntime> quiescent =
runtimes.stream().filter(GrpcChannelRuntime::quiescent).toList();
runtimes.removeAll(quiescent);
return quiescent.size();
}
/** The drain policy in force. */
public GrpcChannelDrainPolicy drainPolicy() {
return drainPolicy;
}
}
@@ -0,0 +1,67 @@
package dev.caskeleton.grpc.client;
import dev.caskeleton.grpc.context.GrpcMetadataKey;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
/**
* Everything one outbound call carries, assembled once and then immutable.
*
* <p>The credential is resolved here, at call time, and held as the header value rather than as the
* provider — so the value that goes on the wire is the one that was valid when the attempt started,
* and a retry assembles a new context rather than replaying an old header.
*/
public record GrpcClientCallContext(
GrpcMethodName method,
GrpcChannelRuntime channel,
GrpcDeadlineBudget deadline,
Map<GrpcMetadataKey, String> metadata,
Optional<String> credential) {
/** Copies the metadata map. */
public GrpcClientCallContext {
if (method == null
|| channel == null
|| deadline == null
|| metadata == null
|| credential == null) {
throw new IllegalArgumentException("a client call context needs every part");
}
metadata = Map.copyOf(metadata);
}
/**
* Assembles a call context, resolving the credential at {@code at}.
*
* @throws IllegalStateException when the deadline is already spent, checked before anything is
* sent rather than after the server has done the work
*/
public static GrpcClientCallContext assemble(
GrpcMethodName method,
GrpcChannelRuntime channel,
GrpcDeadlineBudget deadline,
GrpcClientMetadataPolicy metadataPolicy,
Map<GrpcMetadataKey, String> proposedMetadata,
GrpcCallCredentialProvider credentialProvider,
Instant at) {
if (metadataPolicy == null || credentialProvider == null || at == null) {
throw new IllegalArgumentException(
"assembling a call needs a metadata policy, a credential provider and a moment");
}
if (deadline == null || deadline.expired()) {
throw new IllegalStateException(
"refusing to start '"
+ (method == null ? "an unnamed method" : method.canonical())
+ "': its deadline is already spent");
}
return new GrpcClientCallContext(
method,
channel,
deadline,
metadataPolicy.materialize(proposedMetadata),
credentialProvider.credentialFor(at));
}
}
@@ -0,0 +1,87 @@
package dev.caskeleton.grpc.client;
import dev.caskeleton.grpc.context.GrpcMetadataBudget;
import dev.caskeleton.grpc.context.GrpcMetadataKey;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/**
* What an outbound call is allowed to carry, and what it may not.
*
* <p>Two allowlists rather than one, because a trust boundary changes the answer. Tenant and actor
* metadata is meaningful to a service inside the same trust domain and is an unverified assertion
* to one outside it; sending it across the boundary invites the receiver to trust it. So a
* cross-boundary channel gets the smaller list, and the distinction is a property of the profile
* rather than a decision at each call site.
*
* <p>Authorization is never on either list. It comes from a {@link GrpcCallCredentialProvider}, so
* a caller cannot set it as an ordinary header and bypass rotation.
*/
public record GrpcClientMetadataPolicy(
Set<GrpcMetadataKey> sameTrustDomainKeys,
Set<GrpcMetadataKey> crossTrustBoundaryKeys,
GrpcMetadataBudget budget,
boolean crossesTrustBoundary) {
private static final String AUTHORIZATION = "authorization";
/** Refuses an authorization key on either list, and a cross-boundary list that is not smaller. */
public GrpcClientMetadataPolicy {
if (sameTrustDomainKeys == null || crossTrustBoundaryKeys == null || budget == null) {
throw new IllegalArgumentException("a metadata policy needs both allowlists and a budget");
}
sameTrustDomainKeys = Set.copyOf(sameTrustDomainKeys);
crossTrustBoundaryKeys = Set.copyOf(crossTrustBoundaryKeys);
for (GrpcMetadataKey key : sameTrustDomainKeys) {
requireNotAuthorization(key);
}
for (GrpcMetadataKey key : crossTrustBoundaryKeys) {
requireNotAuthorization(key);
if (!sameTrustDomainKeys.contains(key)) {
throw new IllegalArgumentException(
"cross-boundary key '"
+ key.name()
+ "' is not on the same-domain list; the cross-boundary allowlist is a subset");
}
}
}
private static void requireNotAuthorization(GrpcMetadataKey key) {
if (AUTHORIZATION.equals(key.name())) {
throw new IllegalArgumentException(
"'authorization' is supplied per call by a credential provider, not set as metadata; a "
+ "header set by the application is a header that survives a rotation");
}
}
/** The keys this policy will send on this channel. */
public Set<GrpcMetadataKey> effectiveAllowlist() {
return crossesTrustBoundary ? crossTrustBoundaryKeys : sameTrustDomainKeys;
}
/**
* The metadata that will actually be sent, with anything outside the allowlist dropped.
*
* <p>Dropped rather than refused, unlike the inbound direction: an outbound call that fails
* because a caller attached an unknown correlation header is a worse outcome than one that sends
* a smaller set. The budget is still enforced.
*
* @throws IllegalArgumentException when the surviving set exceeds the budget
*/
public Map<GrpcMetadataKey, String> materialize(Map<GrpcMetadataKey, String> proposed) {
if (proposed == null) {
throw new IllegalArgumentException("proposed metadata must not be null");
}
Set<GrpcMetadataKey> allowed = effectiveAllowlist();
Map<GrpcMetadataKey, String> accepted = new LinkedHashMap<>();
proposed.forEach(
(key, value) -> {
if (allowed.contains(key) && value != null) {
accepted.put(key, value);
}
});
budget.check(accepted);
return Map.copyOf(accepted);
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.grpc.client;
/**
* The load-balancing policies the Stable platform supports.
*
* <p>Two, and the choice follows from what the resolver returns rather than from preference. {@link
* #ROUND_ROBIN} over a resolver that yields one virtual address balances nothing: every request
* goes to the same VIP and the spreading happens, or does not, inside the infrastructure. That
* combination is the most common way a team believes it has client-side load balancing and does
* not.
*/
public enum GrpcLoadBalancingPolicy {
/** Use the first working address. Correct for a single VIP. */
PICK_FIRST("pick_first", false),
/** Rotate across every resolved address. Requires a resolver that returns several. */
ROUND_ROBIN("round_robin", true);
private final String serviceConfigName;
private final boolean requiresMultipleAddresses;
GrpcLoadBalancingPolicy(String serviceConfigName, boolean requiresMultipleAddresses) {
this.serviceConfigName = serviceConfigName;
this.requiresMultipleAddresses = requiresMultipleAddresses;
}
/** The name as it appears in a gRPC service config. */
public String serviceConfigName() {
return serviceConfigName;
}
/** Whether this policy is meaningless unless the resolver returns more than one address. */
public boolean requiresMultipleAddresses() {
return requiresMultipleAddresses;
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.grpc.client;
import dev.caskeleton.grpc.core.GrpcChannelProfileName;
import dev.caskeleton.grpc.resilience.GrpcRetryOwner;
import dev.caskeleton.grpc.security.GrpcTlsProfile;
import java.net.URI;
import java.time.Duration;
import java.util.Set;
/**
* One logical client channel: where it points, how it is secured, who retries on it, and what it
* will carry.
*
* <p>Named for the traffic rather than the backend, because the same backend reached with two
* different service levels is two channels. A long-lived subscription and a user-facing read share
* a host and share almost nothing else: different deadlines, different retry ownership, different
* keepalive, and a connection pool whose saturation by one starves the other.
*/
public record GrpcNamedChannelProfile(
GrpcChannelProfileName name,
URI target,
GrpcLoadBalancingPolicy loadBalancingPolicy,
GrpcRetryOwner retryOwner,
GrpcTlsProfile tls,
long maxInboundMessageBytes,
int maxInboundMetadataBytes,
Duration keepAliveTime,
boolean waitForReadyDefault) {
/** The resolver schemes the Stable platform accepts in a target. */
private static final Set<String> STABLE_SCHEMES = Set.of("dns", "static", "unix");
/** Refuses a target or a combination the Stable platform does not support. */
public GrpcNamedChannelProfile {
if (name == null
|| target == null
|| loadBalancingPolicy == null
|| retryOwner == null
|| tls == null) {
throw new IllegalArgumentException("a channel profile needs every part named");
}
String scheme = target.getScheme();
if (scheme == null || scheme.isBlank()) {
throw new IllegalArgumentException(
"a channel target carries its resolver scheme, e.g. 'dns:///documents:9090'; got '"
+ target
+ "'");
}
if (!STABLE_SCHEMES.contains(scheme)) {
throw new IllegalArgumentException(
"resolver scheme '"
+ scheme
+ "' is not Stable; xDS and custom resolvers are Advanced capabilities behind a "
+ "feature flag. Stable schemes are "
+ STABLE_SCHEMES.stream().sorted().toList());
}
if (maxInboundMessageBytes < 1L || maxInboundMetadataBytes < 1) {
throw new IllegalArgumentException("message and metadata bounds must be positive");
}
if (keepAliveTime == null || keepAliveTime.isZero() || keepAliveTime.isNegative()) {
throw new IllegalArgumentException(
"a keep-alive interval must be positive; without one an idle connection is discovered "
+ "dead by the next request rather than by a probe");
}
if (waitForReadyDefault && retryOwner == GrpcRetryOwner.NONE) {
throw new IllegalArgumentException(
"wait-for-ready with no retry owner queues a call that nobody will re-attempt");
}
}
/** A read-path channel over DNS with a single VIP. */
public static GrpcNamedChannelProfile virtualIp(
String name, URI target, GrpcTlsProfile tls, GrpcRetryOwner retryOwner) {
return new GrpcNamedChannelProfile(
new GrpcChannelProfileName(name),
target,
GrpcLoadBalancingPolicy.PICK_FIRST,
retryOwner,
tls,
1024L * 1024L,
8192,
Duration.ofSeconds(30),
false);
}
/** A channel over a headless DNS record with several addresses. */
public static GrpcNamedChannelProfile headless(
String name, URI target, GrpcTlsProfile tls, GrpcRetryOwner retryOwner) {
return new GrpcNamedChannelProfile(
new GrpcChannelProfileName(name),
target,
GrpcLoadBalancingPolicy.ROUND_ROBIN,
retryOwner,
tls,
1024L * 1024L,
8192,
Duration.ofSeconds(30),
false);
}
}
@@ -0,0 +1,33 @@
package dev.caskeleton.grpc.client;
import dev.caskeleton.grpc.core.GrpcChannelProfileName;
/**
* Which generated stub type this factory may build, on which channel, in which calling style.
*
* <p>An allowlist entry rather than a lookup. A factory that will build any type it is handed is a
* factory through which an unregistered service gets a channel with nobody's policy on it, and the
* first time anyone notices is when that call has no deadline.
*
* @param <S> the generated stub type
*/
public record GrpcStubDescriptor<S>(
Class<S> stubType, GrpcChannelProfileName channelProfile, Style style) {
/** How the caller interacts with the stub. */
public enum Style {
/** Blocks until the response arrives. */
BLOCKING,
/** Returns a future. */
FUTURE,
/** Delivers through a stream observer. */
ASYNC
}
/** Requires all three parts. */
public GrpcStubDescriptor {
if (stubType == null || channelProfile == null || style == null) {
throw new IllegalArgumentException("a stub descriptor names its type, channel and style");
}
}
}
@@ -0,0 +1,68 @@
package dev.caskeleton.grpc.client;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget;
import dev.caskeleton.grpc.deadline.GrpcDeadlineCalculator;
import dev.caskeleton.grpc.policy.GrpcMethodPolicy;
import dev.caskeleton.grpc.policy.GrpcMethodPolicyCatalog;
import dev.caskeleton.grpc.resilience.GrpcWaitForReadyDecision;
import dev.caskeleton.grpc.resilience.GrpcWaitForReadyProfile;
import dev.caskeleton.grpc.resilience.GrpcWaitForReadyValidator;
import java.time.Duration;
/**
* Applies the method policy immediately before a call goes out.
*
* <p>At call time rather than at stub construction, because the two inputs that matter are only
* known then: how much of the caller's deadline is left, and whether the channel is ready. A stub
* configured once with a fixed deadline gives every call the same budget regardless of how much of
* it the caller has already spent.
*/
public final class GrpcStubPolicyApplier {
private final GrpcMethodPolicyCatalog catalog;
private final GrpcWaitForReadyProfile waitForReadyProfile;
/** Binds an applier to the catalog it enforces and the channel's wait-for-ready profile. */
public GrpcStubPolicyApplier(
GrpcMethodPolicyCatalog catalog, GrpcWaitForReadyProfile waitForReadyProfile) {
if (catalog == null || waitForReadyProfile == null) {
throw new IllegalArgumentException(
"a policy applier needs a catalog and a wait-for-ready profile");
}
this.catalog = catalog;
this.waitForReadyProfile = waitForReadyProfile;
}
/** What the applier decided for one call. */
public record CallPolicy(
GrpcMethodPolicy method, GrpcDeadlineBudget deadline, GrpcWaitForReadyDecision waitForReady) {
/** Requires all three. */
public CallPolicy {
if (method == null || deadline == null || waitForReady == null) {
throw new IllegalArgumentException(
"a call policy carries the method, deadline and queueing");
}
}
}
/**
* The effective policy for one call.
*
* @param parentRemaining what the caller's own deadline leaves, or null when there is none
* @throws IllegalStateException when the method has no registered policy
*/
public CallPolicy applyTo(GrpcMethodName method, Duration parentRemaining) {
GrpcMethodPolicy policy = catalog.require(method);
GrpcDeadlineBudget deadline = GrpcDeadlineCalculator.forInboundCall(policy, parentRemaining);
GrpcWaitForReadyDecision waitForReady =
GrpcWaitForReadyValidator.decide(policy, waitForReadyProfile, deadline);
return new CallPolicy(policy, deadline, waitForReady);
}
/** The catalog this applier enforces. */
public GrpcMethodPolicyCatalog catalog() {
return catalog;
}
}
@@ -0,0 +1,119 @@
package dev.caskeleton.grpc.client;
import dev.caskeleton.grpc.core.GrpcChannelProfileName;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
/**
* Builds registered generated stubs, and nothing else.
*
* <p>Two refusals define it. It will not build a stub type nobody registered, so a service cannot
* acquire a channel without a policy; and it never returns a {@code Channel} or a builder, so
* application code has no way to construct one itself. Both are what make the raw-API import rule
* enforceable rather than merely stated: there is nothing to reach for.
*/
public final class GrpcTypedStubFactory {
private final GrpcChannelRuntimeRegistry registry;
private final Map<Class<?>, GrpcStubDescriptor<?>> descriptors = new LinkedHashMap<>();
private final Map<Class<?>, Function<GrpcChannelRuntime, ?>> builders = new LinkedHashMap<>();
private final GrpcStubPolicyApplier policyApplier;
private GrpcTypedStubFactory(
GrpcChannelRuntimeRegistry registry, GrpcStubPolicyApplier policyApplier) {
this.registry = registry;
this.policyApplier = policyApplier;
}
/** A builder bound to the channel registry and the policy applier. */
public static Builder builder(
GrpcChannelRuntimeRegistry registry, GrpcStubPolicyApplier policyApplier) {
if (registry == null || policyApplier == null) {
throw new IllegalArgumentException(
"a stub factory needs a channel registry and a policy applier");
}
return new Builder(registry, policyApplier);
}
/**
* A stub for {@code stubType}, on the channel its descriptor names.
*
* @throws IllegalArgumentException when the type is not registered
*/
public <S> S stubFor(Class<S> stubType) {
GrpcStubDescriptor<?> descriptor = descriptors.get(stubType);
if (descriptor == null) {
throw new IllegalArgumentException(
"stub type '"
+ (stubType == null ? "null" : stubType.getName())
+ "' is not registered; an unregistered stub would get a channel with no method "
+ "policy, no deadline and no credential provider");
}
GrpcChannelRuntime runtime = registry.require(descriptor.channelProfile());
return stubType.cast(builders.get(stubType).apply(runtime));
}
/** The descriptor for a registered stub type. */
public <S> GrpcStubDescriptor<?> descriptorFor(Class<S> stubType) {
GrpcStubDescriptor<?> descriptor = descriptors.get(stubType);
if (descriptor == null) {
throw new IllegalArgumentException("stub type is not registered: " + stubType);
}
return descriptor;
}
/** Every registered stub type. */
public Set<Class<?>> registeredStubTypes() {
return Set.copyOf(descriptors.keySet());
}
/** The policy applier every call goes through. */
public GrpcStubPolicyApplier policyApplier() {
return policyApplier;
}
/** Registers stub types before the factory is used. */
public static final class Builder {
private final GrpcTypedStubFactory factory;
private Builder(GrpcChannelRuntimeRegistry registry, GrpcStubPolicyApplier policyApplier) {
this.factory = new GrpcTypedStubFactory(registry, policyApplier);
}
/**
* Registers one generated stub type.
*
* @param stubBuilder how to build it from a channel runtime. Takes the runtime rather than a
* raw channel, so a registration cannot smuggle one out.
*/
public <S> Builder register(
GrpcStubDescriptor<S> descriptor, Function<GrpcChannelRuntime, S> stubBuilder) {
if (descriptor == null || stubBuilder == null) {
throw new IllegalArgumentException("a registration needs a descriptor and a builder");
}
if (factory.descriptors.putIfAbsent(descriptor.stubType(), descriptor) != null) {
throw new IllegalArgumentException(
"stub type '" + descriptor.stubType().getName() + "' is registered twice");
}
factory.builders.put(descriptor.stubType(), stubBuilder);
return this;
}
/** Builds the factory. */
public GrpcTypedStubFactory build() {
if (factory.descriptors.isEmpty()) {
throw new IllegalStateException(
"a stub factory with no registered types can build nothing and refuses everything");
}
return factory;
}
}
/** The channel profile a registered stub type uses. */
public GrpcChannelProfileName channelProfileFor(Class<?> stubType) {
return descriptorFor(stubType).channelProfile();
}
}
@@ -0,0 +1,128 @@
package dev.caskeleton.grpc.client;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.core.GrpcChannelProfileName;
import java.time.Duration;
import java.time.Instant;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcChannelRuntimeRegistryTest {
private static final GrpcChannelProfileName PROFILE =
new GrpcChannelProfileName("documents-read");
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
private static GrpcChannelGeneration generation(long number) {
return new GrpcChannelGeneration(PROFILE, number, "sha256:config-" + number, NOW);
}
private final GrpcChannelRuntimeRegistry registry =
new GrpcChannelRuntimeRegistry(GrpcChannelDrainPolicy.stable());
@Test
@DisplayName("a channel is created once and reused, not built per request")
void aChannelIsReusedAcrossCalls() {
GrpcChannelRuntime installed = registry.install(generation(1L));
assertThat(registry.require(PROFILE)).isSameAs(installed);
assertThat(registry.require(PROFILE)).isSameAs(installed);
assertThat(registry.find(PROFILE)).contains(installed);
}
@Test
@DisplayName("installing twice is refused; rotation is the way to replace a generation")
void installingTwiceIsRefused() {
registry.install(generation(1L));
assertThatThrownBy(() -> registry.install(generation(2L)))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("use rotate()");
}
@Test
@DisplayName("an unconfigured profile fails loudly rather than returning nothing")
void anUnconfiguredProfileFailsLoudly() {
assertThatThrownBy(() -> registry.require(PROFILE))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("no channel runtime is installed");
assertThat(registry.find(PROFILE)).isEmpty();
}
@Test
@DisplayName("a rotation swaps the pointer and starts draining the previous generation")
void rotationSwapsThenDrains() {
GrpcChannelRuntime first = registry.install(generation(1L));
first.startUnaryCall();
GrpcChannelRuntime second = registry.rotate(generation(2L), NOW);
assertThat(registry.require(PROFILE)).isSameAs(second);
assertThat(first.draining()).isTrue();
assertThat(registry.draining(PROFILE)).containsExactly(first);
assertThat(first.inFlightUnaryCalls()).isEqualTo(1);
}
@Test
@DisplayName("a draining generation admits no new work, and existing work finishes")
void aDrainingGenerationAdmitsNoNewWork() {
GrpcChannelRuntime first = registry.install(generation(1L));
first.startUnaryCall();
first.openStream();
registry.rotate(generation(2L), NOW);
assertThat(first.startUnaryCall()).isFalse();
assertThat(first.openStream()).isFalse();
assertThat(first.quiescent()).isFalse();
first.finishUnaryCall();
first.closeStream();
assertThat(first.quiescent()).isTrue();
}
@Test
@DisplayName("a quiescent draining generation is retired")
void quiescentGenerationsAreRetired() {
GrpcChannelRuntime first = registry.install(generation(1L));
first.startUnaryCall();
registry.rotate(generation(2L), NOW);
assertThat(registry.retireQuiescent(PROFILE)).isZero();
first.finishUnaryCall();
assertThat(registry.retireQuiescent(PROFILE)).isEqualTo(1);
assertThat(registry.draining(PROFILE)).isEmpty();
}
@Test
@DisplayName("a rotation that goes backwards is refused")
void rotationIsMonotonic() {
registry.install(generation(2L));
assertThatThrownBy(() -> registry.rotate(generation(1L), NOW))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("does not supersede");
}
@Test
@DisplayName("unary and stream drain budgets are separate")
void unaryAndStreamDrainBudgetsAreSeparate() {
GrpcChannelDrainPolicy policy = GrpcChannelDrainPolicy.stable();
assertThat(policy.unaryDrain()).isLessThan(policy.streamDrain());
assertThat(policy.forceCancel()).isTrue();
assertThatThrownBy(() -> new GrpcChannelDrainPolicy(Duration.ZERO, Duration.ZERO, false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("alive forever");
}
@Test
@DisplayName("the runtime does not hand out a raw channel")
void theRuntimeExposesNoRawChannel() {
assertThat(GrpcChannelRuntime.class.getMethods())
.extracting(java.lang.reflect.Method::getReturnType)
.extracting(Class::getName)
.noneMatch(name -> name.startsWith("io.grpc."));
}
}
@@ -0,0 +1,165 @@
package dev.caskeleton.grpc.client;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.context.GrpcMetadataBudget;
import dev.caskeleton.grpc.context.GrpcMetadataKey;
import dev.caskeleton.grpc.core.GrpcChannelProfileName;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget;
import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcClientMetadataPolicyTest {
private static final GrpcMetadataKey CORRELATION = GrpcMetadataKey.ascii("x-correlation-id");
private static final GrpcMetadataKey TENANT = GrpcMetadataKey.ascii("x-tenant-context");
private static final GrpcMethodName GET =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
private static GrpcClientMetadataPolicy policy(boolean crossesBoundary) {
return new GrpcClientMetadataPolicy(
Set.of(CORRELATION, TENANT),
Set.of(CORRELATION),
GrpcMetadataBudget.standard(),
crossesBoundary);
}
private static GrpcChannelRuntime runtime() {
return new GrpcChannelRuntime(
new GrpcChannelGeneration(
new GrpcChannelProfileName("documents-read"), 1L, "sha256:config-1", NOW));
}
@Test
@DisplayName("authorization is supplied per call, never set as ordinary metadata")
void authorizationIsNotOrdinaryMetadata() {
assertThatThrownBy(
() ->
new GrpcClientMetadataPolicy(
Set.of(GrpcMetadataKey.ascii("authorization")),
Set.of(),
GrpcMetadataBudget.standard(),
false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("survives a rotation");
}
@Test
@DisplayName("a channel that crosses a trust boundary sends the smaller allowlist")
void aCrossBoundaryChannelSendsLess() {
Map<GrpcMetadataKey, String> proposed = Map.of(CORRELATION, "corr-1", TENANT, "tenant-1");
assertThat(policy(false).materialize(proposed)).containsOnlyKeys(CORRELATION, TENANT);
assertThat(policy(true).materialize(proposed)).containsOnlyKeys(CORRELATION);
assertThat(policy(true).effectiveAllowlist()).containsExactly(CORRELATION);
}
@Test
@DisplayName("the cross-boundary allowlist must be a subset of the same-domain one")
void theCrossBoundaryListMustBeASubset() {
assertThatThrownBy(
() ->
new GrpcClientMetadataPolicy(
Set.of(CORRELATION), Set.of(TENANT), GrpcMetadataBudget.standard(), true))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("is a subset");
}
@Test
@DisplayName("a key outside the allowlist is dropped outbound rather than failing the call")
void unknownKeysAreDroppedOutbound() {
Map<GrpcMetadataKey, String> proposed = new LinkedHashMap<>();
proposed.put(CORRELATION, "corr-1");
proposed.put(GrpcMetadataKey.ascii("x-internal-hop"), "flow-3");
assertThat(policy(false).materialize(proposed)).containsOnlyKeys(CORRELATION);
}
@Test
@DisplayName("the budget is still enforced on what survives the allowlist")
void theBudgetIsEnforcedOnTheSurvivors() {
GrpcClientMetadataPolicy tight =
new GrpcClientMetadataPolicy(
Set.of(CORRELATION), Set.of(CORRELATION), new GrpcMetadataBudget(200, 40, 4), false);
assertThatThrownBy(() -> tight.materialize(Map.of(CORRELATION, "x".repeat(64))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("user-defined metadata");
}
@Test
@DisplayName("the credential is resolved at call time, not captured when the stub was built")
void theCredentialIsResolvedPerCall() {
GrpcCallCredentialProvider provider =
at -> Optional.of("Bearer token-at-" + at.getEpochSecond());
GrpcDeadlineBudget deadline =
GrpcDeadlineBudget.forEntryPoint(
Duration.ofSeconds(5), GrpcDeadlineProfile.of(Duration.ofSeconds(10)));
GrpcClientCallContext first =
GrpcClientCallContext.assemble(
GET, runtime(), deadline, policy(false), Map.of(CORRELATION, "corr-1"), provider, NOW);
GrpcClientCallContext later =
GrpcClientCallContext.assemble(
GET,
runtime(),
deadline,
policy(false),
Map.of(CORRELATION, "corr-1"),
provider,
NOW.plusSeconds(60));
assertThat(first.credential()).contains("Bearer token-at-" + NOW.getEpochSecond());
assertThat(later.credential()).isNotEqualTo(first.credential());
assertThat(GrpcCallCredentialProvider.anonymous().credentialFor(NOW)).isEmpty();
}
@Test
@DisplayName("a call whose deadline is already spent is refused before anything is sent")
void aSpentDeadlineIsRefusedBeforeSending() {
assertThatThrownBy(
() ->
GrpcClientCallContext.assemble(
GET,
runtime(),
new GrpcDeadlineBudget(
Duration.ZERO, GrpcDeadlineProfile.of(Duration.ofSeconds(2))),
policy(false),
Map.of(),
GrpcCallCredentialProvider.anonymous(),
NOW))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("deadline is already spent");
}
@Test
@DisplayName("a call context copies its metadata, so it cannot change after the call started")
void aCallContextIsImmutable() {
Map<GrpcMetadataKey, String> proposed = new LinkedHashMap<>();
proposed.put(CORRELATION, "corr-1");
GrpcClientCallContext context =
GrpcClientCallContext.assemble(
GET,
runtime(),
GrpcDeadlineBudget.forEntryPoint(
Duration.ofSeconds(5), GrpcDeadlineProfile.of(Duration.ofSeconds(10))),
policy(false),
proposed,
GrpcCallCredentialProvider.anonymous(),
NOW);
proposed.put(TENANT, "added-after-the-fact");
assertThat(context.metadata()).containsOnlyKeys(CORRELATION);
}
}
@@ -0,0 +1,159 @@
package dev.caskeleton.grpc.client;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.core.GrpcChannelProfileName;
import dev.caskeleton.grpc.resilience.GrpcRetryOwner;
import dev.caskeleton.grpc.security.GrpcAuthenticationProfile;
import dev.caskeleton.grpc.security.GrpcTlsProfile;
import java.net.URI;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcNamedChannelProfileTest {
private static final GrpcTlsProfile TLS =
GrpcTlsProfile.serverAuthenticated(GrpcTlsProfile.Environment.PROD, "trust-bundle");
@Test
@DisplayName("a target must carry a Stable resolver scheme")
void aTargetCarriesAStableResolverScheme() {
assertThat(
GrpcNamedChannelProfile.virtualIp(
"documents-read",
URI.create("dns:///documents:9090"),
TLS,
GrpcRetryOwner.GRPC_PLATFORM)
.target()
.getScheme())
.isEqualTo("dns");
assertThatThrownBy(
() ->
GrpcNamedChannelProfile.virtualIp(
"documents-read",
URI.create("documents:9090"),
TLS,
GrpcRetryOwner.GRPC_PLATFORM))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("xds and custom schemes are refused as Advanced")
void xdsIsRefusedAsAdvanced() {
assertThatThrownBy(
() ->
GrpcNamedChannelProfile.virtualIp(
"documents-read",
URI.create("xds:///documents"),
TLS,
GrpcRetryOwner.GRPC_PLATFORM))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Advanced");
}
@Test
@DisplayName("the Stable load balancing policies are pick_first and round_robin")
void stableLoadBalancingIsTwoPolicies() {
assertThat(GrpcLoadBalancingPolicy.values())
.containsExactly(GrpcLoadBalancingPolicy.PICK_FIRST, GrpcLoadBalancingPolicy.ROUND_ROBIN);
assertThat(GrpcLoadBalancingPolicy.PICK_FIRST.serviceConfigName()).isEqualTo("pick_first");
assertThat(GrpcLoadBalancingPolicy.ROUND_ROBIN.requiresMultipleAddresses()).isTrue();
assertThat(GrpcLoadBalancingPolicy.PICK_FIRST.requiresMultipleAddresses()).isFalse();
}
@Test
@DisplayName("round-robin over a single-address target is reported, not silently accepted")
void roundRobinOverOneAddressIsReported() {
GrpcNamedChannelProfile headless =
GrpcNamedChannelProfile.headless(
"documents-stream",
URI.create("dns:///documents-headless:9090"),
TLS,
GrpcRetryOwner.NONE);
assertThat(
GrpcChannelProfileValidator.violations(
List.of(headless), Map.of("documents-stream", 1)))
.singleElement()
.satisfies(violation -> assertThat(violation).contains("spreading that is not happening"));
assertThat(
GrpcChannelProfileValidator.violations(
List.of(headless), Map.of("documents-stream", 4)))
.isEmpty();
}
@Test
@DisplayName("a duplicated profile name is reported")
void aDuplicatedProfileNameIsReported() {
GrpcNamedChannelProfile first =
GrpcNamedChannelProfile.virtualIp(
"documents-read", URI.create("dns:///a:9090"), TLS, GrpcRetryOwner.NONE);
GrpcNamedChannelProfile second =
GrpcNamedChannelProfile.virtualIp(
"documents-read", URI.create("dns:///b:9090"), TLS, GrpcRetryOwner.NONE);
assertThat(GrpcChannelProfileValidator.violations(List.of(first, second), Map.of()))
.anySatisfy(violation -> assertThat(violation).contains("declared twice"));
}
@Test
@DisplayName("wait-for-ready with no retry owner is refused")
void waitForReadyWithoutARetryOwnerIsRefused() {
assertThatThrownBy(
() ->
new GrpcNamedChannelProfile(
new GrpcChannelProfileName("documents-read"),
URI.create("dns:///documents:9090"),
GrpcLoadBalancingPolicy.PICK_FIRST,
GrpcRetryOwner.NONE,
TLS,
1024L,
1024,
Duration.ofSeconds(30),
true))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("nobody will re-attempt");
}
@Test
@DisplayName("a keep-alive interval is required rather than left at the default")
void aKeepAliveIntervalIsRequired() {
assertThatThrownBy(
() ->
new GrpcNamedChannelProfile(
new GrpcChannelProfileName("documents-read"),
URI.create("dns:///documents:9090"),
GrpcLoadBalancingPolicy.PICK_FIRST,
GrpcRetryOwner.NONE,
TLS,
1024L,
1024,
Duration.ZERO,
false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("discovered dead by the next request");
}
@Test
@DisplayName("a token profile on a plaintext channel is refused")
void aTokenOnPlaintextIsRefused() {
GrpcNamedChannelProfile plaintext =
GrpcNamedChannelProfile.virtualIp(
"local-dev",
URI.create("dns:///localhost:9090"),
GrpcTlsProfile.plaintextLocal(GrpcTlsProfile.Environment.LOCAL),
GrpcRetryOwner.NONE);
assertThatThrownBy(
() ->
GrpcChannelProfileValidator.requireCompatibleAuthentication(
plaintext, GrpcAuthenticationProfile.BEARER_TOKEN))
.isInstanceOf(IllegalStateException.class);
GrpcChannelProfileValidator.requireCompatibleAuthentication(
plaintext, GrpcAuthenticationProfile.NONE);
}
}
@@ -0,0 +1,129 @@
package dev.caskeleton.grpc.client;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.core.GrpcChannelProfileName;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile;
import dev.caskeleton.grpc.policy.GrpcMethodPolicy;
import dev.caskeleton.grpc.policy.GrpcMethodPolicyCatalog;
import dev.caskeleton.grpc.resilience.GrpcWaitForReadyProfile;
import java.time.Duration;
import java.time.Instant;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcTypedStubFactoryTest {
private static final GrpcChannelProfileName PROFILE =
new GrpcChannelProfileName("documents-read");
private static final GrpcMethodName GET =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
/** Stands in for a generated blocking stub. */
private record DocumentStub(GrpcChannelGeneration boundTo) {}
/** A stub type nobody registered. */
private record UnregisteredStub() {}
private GrpcChannelRuntimeRegistry registryWithChannel() {
GrpcChannelRuntimeRegistry registry =
new GrpcChannelRuntimeRegistry(GrpcChannelDrainPolicy.stable());
registry.install(new GrpcChannelGeneration(PROFILE, 1L, "sha256:config-1", NOW));
return registry;
}
private static GrpcStubPolicyApplier policyApplier() {
return new GrpcStubPolicyApplier(
GrpcMethodPolicyCatalog.builder()
.register(
GrpcMethodPolicy.readOnlyUnary(GET, GrpcDeadlineProfile.of(Duration.ofSeconds(2))))
.build(),
GrpcWaitForReadyProfile.disabled());
}
private GrpcTypedStubFactory factory(GrpcChannelRuntimeRegistry registry) {
return GrpcTypedStubFactory.builder(registry, policyApplier())
.register(
new GrpcStubDescriptor<>(
DocumentStub.class, PROFILE, GrpcStubDescriptor.Style.BLOCKING),
runtime -> new DocumentStub(runtime.generation()))
.build();
}
@Test
@DisplayName("only registered stub types are built")
void onlyRegisteredStubTypesAreBuilt() {
GrpcTypedStubFactory factory = factory(registryWithChannel());
assertThat(factory.stubFor(DocumentStub.class).boundTo().generation()).isEqualTo(1L);
assertThatThrownBy(() -> factory.stubFor(UnregisteredStub.class))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("no method policy");
}
@Test
@DisplayName("a stub is bound to the channel profile its descriptor names")
void aStubIsBoundToItsChannelProfile() {
GrpcTypedStubFactory factory = factory(registryWithChannel());
assertThat(factory.channelProfileFor(DocumentStub.class)).isEqualTo(PROFILE);
assertThat(factory.descriptorFor(DocumentStub.class).style())
.isEqualTo(GrpcStubDescriptor.Style.BLOCKING);
assertThat(factory.registeredStubTypes()).containsExactly(DocumentStub.class);
}
@Test
@DisplayName("a stub built after a rotation is on the new generation")
void aStubFollowsTheCurrentGeneration() {
GrpcChannelRuntimeRegistry registry = registryWithChannel();
GrpcTypedStubFactory factory = factory(registry);
registry.rotate(new GrpcChannelGeneration(PROFILE, 2L, "sha256:config-2", NOW), NOW);
assertThat(factory.stubFor(DocumentStub.class).boundTo().generation()).isEqualTo(2L);
}
@Test
@DisplayName("a factory with no registrations refuses to build")
void anEmptyFactoryIsRefused() {
assertThatThrownBy(
() -> GrpcTypedStubFactory.builder(registryWithChannel(), policyApplier()).build())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("refuses everything");
}
@Test
@DisplayName("registering one stub type twice is refused")
void duplicateRegistrationIsRefused() {
GrpcTypedStubFactory.Builder builder =
GrpcTypedStubFactory.builder(registryWithChannel(), policyApplier())
.register(
new GrpcStubDescriptor<>(
DocumentStub.class, PROFILE, GrpcStubDescriptor.Style.BLOCKING),
runtime -> new DocumentStub(runtime.generation()));
assertThatThrownBy(
() ->
builder.register(
new GrpcStubDescriptor<>(
DocumentStub.class, PROFILE, GrpcStubDescriptor.Style.FUTURE),
runtime -> new DocumentStub(runtime.generation())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("registered twice");
}
@Test
@DisplayName("the effective deadline is computed at call time from what the caller has left")
void theDeadlineIsComputedAtCallTime() {
GrpcStubPolicyApplier applier = policyApplier();
GrpcStubPolicyApplier.CallPolicy generous = applier.applyTo(GET, Duration.ofSeconds(30));
GrpcStubPolicyApplier.CallPolicy tight = applier.applyTo(GET, Duration.ofMillis(300));
assertThat(generous.deadline().remaining()).isEqualTo(Duration.ofMillis(1800));
assertThat(tight.deadline().remaining()).isEqualTo(Duration.ofMillis(100));
assertThat(tight.waitForReady().queue()).isFalse();
}
}
+12
View File
@@ -0,0 +1,12 @@
apply plugin: 'java-library'
// Contract governance: Buf format/lint/breaking policy, the single codegen owner declaration, and
// the descriptor/schema-hash release artifact with its consumer-compile gate.
//
// The Buf rules are implemented here rather than shelled out to the Buf CLI (adaptation D5): the
// CLI is not present in this toolchain, and a gate that silently no-ops when a binary is missing is
// worse than one that computes the same judgement from the committed schema.
dependencies {
api project(':grpc:grpc-core-api')
api project(':grpc:grpc-proto-contract')
}
+84
View File
@@ -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,43 @@
package dev.caskeleton.grpc.codegen;
/**
* The Buf breaking-change categories, ordered from strictest to loosest.
*
* <p>The Stable gate is {@link #FILE}, and the reason is the pair of properties below: only FILE
* and PACKAGE detect a change that keeps the wire format identical while breaking every generated
* consumer's compile. A team that gates on WIRE ships a field rename, watches its own integration
* tests pass, and finds out at the consumer's next build.
*/
public enum GrpcBreakingCategory {
/** Strictest: also catches file moves, package moves and Java option changes. */
FILE(true, true),
/** Catches type and field source breaks within a package, but not a file move. */
PACKAGE(true, false),
/** Wire plus JSON name compatibility. Source breaks pass. */
WIRE_JSON(false, false),
/** Binary wire compatibility only. Source breaks pass. */
WIRE(false, false);
private final boolean detectsSourceBreak;
private final boolean detectsFileMove;
GrpcBreakingCategory(boolean detectsSourceBreak, boolean detectsFileMove) {
this.detectsSourceBreak = detectsSourceBreak;
this.detectsFileMove = detectsFileMove;
}
/** Whether this category fails a change that stops a generated consumer compiling. */
public boolean detectsSourceBreak() {
return detectsSourceBreak;
}
/** Whether this category fails a file or package relocation. */
public boolean detectsFileMove() {
return detectsFileMove;
}
/** The category the Stable public API is gated on. */
public static GrpcBreakingCategory stableGate() {
return FILE;
}
}
@@ -0,0 +1,75 @@
package dev.caskeleton.grpc.codegen;
import java.util.List;
import java.util.Set;
/**
* The schema governance this repository runs, as a value the build and the tests both read.
*
* <p>Buf's CLI is not part of this toolchain (adaptation D5), so the four lifecycle task names
* below are the contract a CI environment fulfils and {@code GrpcProtoContractValidator} is what
* actually fails a build here. Keeping the task names in the policy rather than only in a workflow
* file means a missing stage is a test failure rather than a stage nobody noticed was gone.
*/
public record GrpcBufPolicy(
GrpcBreakingCategory breakingCategory,
boolean formatEnforced,
boolean lintEnforced,
GrpcSchemaBaseline baseline) {
/** The lifecycle stages a compliant schema pipeline runs, in order. */
private static final List<String> REQUIRED_TASKS =
List.of("bufFormatCheck", "bufLint", "bufBuild", "bufBreaking");
/** Refuses a policy that would let a source break through. */
public GrpcBufPolicy {
if (breakingCategory == null) {
throw new IllegalArgumentException("a Buf policy needs a breaking category");
}
if (baseline == null) {
throw new IllegalArgumentException(
"a Buf policy needs a released baseline to compare against");
}
if (!breakingCategory.detectsSourceBreak()) {
throw new IllegalArgumentException(
"the Stable public API may not be gated on "
+ breakingCategory
+ ": wire compatibility alone lets a rename through as compatible while every "
+ "generated consumer stops compiling");
}
if (!formatEnforced || !lintEnforced) {
throw new IllegalArgumentException(
"format and lint are not optional; a schema that is not linted is a schema whose style "
+ "rules are whatever the last author preferred");
}
}
/** The Stable policy: FILE breaking, format and lint enforced. */
public static GrpcBufPolicy stable(GrpcSchemaBaseline baseline) {
return new GrpcBufPolicy(GrpcBreakingCategory.stableGate(), true, true, baseline);
}
/** The lifecycle task names a compliant pipeline registers. */
public static List<String> requiredTasks() {
return REQUIRED_TASKS;
}
/**
* The task names missing from {@code registeredTasks}.
*
* @return an empty set when the pipeline is complete
*/
public static Set<String> missingTasks(Set<String> registeredTasks) {
if (registeredTasks == null) {
throw new IllegalArgumentException("the registered task set must not be null");
}
return REQUIRED_TASKS.stream()
.filter(task -> !registeredTasks.contains(task))
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
/** Whether a candidate schema hash may be released without a breaking review. */
public boolean unchangedFromBaseline(String candidateSchemaHash) {
return baseline.matches(candidateSchemaHash);
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.grpc.codegen;
/**
* Who generates Java from the schema, with what versions, into where.
*
* <p>One owner, named. Two generators for one schema is the state in which a type exists twice with
* different options and the classpath decides which one a consumer gets; refusing a second owner is
* cheaper than diagnosing that.
*
* <p>Both version sources are required to be the managed BOM rather than a literal. A pinned
* protobuf version beside a BOM-managed gRPC version is how the runtime and the generator drift
* into a combination nobody tested, and the symptom is a {@code NoSuchMethodError} in generated
* code.
*/
public record GrpcCodegenManifest(
String codegenOwner,
String protobufVersionSource,
String grpcJavaVersionSource,
GrpcCodegenOutput output,
GrpcGeneratedPackagePolicy packagePolicy) {
/** The only accepted version source: whatever the build's managed platform resolves. */
public static final String MANAGED_VERSION_SOURCE = "managed-platform";
/** Refuses a second owner, a literal version, or overlapping package namespaces. */
public GrpcCodegenManifest {
if (codegenOwner == null || codegenOwner.isBlank()) {
throw new IllegalArgumentException("code generation needs exactly one named owner");
}
if (output == null || packagePolicy == null) {
throw new IllegalArgumentException("a codegen manifest needs an output and a package policy");
}
requireManagedVersion(protobufVersionSource, "protobuf");
requireManagedVersion(grpcJavaVersionSource, "grpc-java");
packagePolicy.requireDisjoint();
}
private static void requireManagedVersion(String source, String what) {
if (!MANAGED_VERSION_SOURCE.equals(source)) {
throw new IllegalArgumentException(
what
+ " version must come from the managed platform, not from a literal declaration; got '"
+ source
+ "'. A pinned generator beside a managed runtime is a combination nobody tested.");
}
}
/**
* This repository's manifest.
*
* <p>The owner is the Gradle protobuf plugin. It does not run in this build yet — adaptation D6
* records why, and this manifest is what a future decision to turn it on has to satisfy rather
* than replace.
*/
public static GrpcCodegenManifest caSkeleton() {
return new GrpcCodegenManifest(
"gradle-protobuf-plugin",
MANAGED_VERSION_SOURCE,
MANAGED_VERSION_SOURCE,
GrpcCodegenOutput.standard(),
new GrpcGeneratedPackagePolicy(
java.util.Set.of("dev.caskeleton"),
java.util.Set.of("hyeonworks.grpc.common.v1.generated")));
}
/**
* Fails when a second generator claims the same schema.
*
* @throws IllegalStateException when {@code otherOwner} is not this manifest's owner
*/
public void requireSoleOwner(String otherOwner) {
if (!codegenOwner.equals(otherOwner)) {
throw new IllegalStateException(
"Java code generation for this schema is owned by '"
+ codegenOwner
+ "'; '"
+ otherOwner
+ "' may not also generate it");
}
}
}
@@ -0,0 +1,58 @@
package dev.caskeleton.grpc.codegen;
/**
* Where generated artifacts land.
*
* <p>Everything here is required to be inside a build directory. A generator that writes into a
* source tree produces files that get committed, then edited, then silently reverted by the next
* regeneration — and the diff that reverts them looks like the generator working correctly.
*/
public record GrpcCodegenOutput(
String javaOutputDirectory,
String grpcJavaOutputDirectory,
String descriptorSetPath,
boolean includeSourceInfo) {
private static final String BUILD_ROOT = "build/";
/** Requires every path to be build-directory relative. */
public GrpcCodegenOutput {
requireBuildRelative(javaOutputDirectory, "java output directory");
requireBuildRelative(grpcJavaOutputDirectory, "grpc-java output directory");
requireBuildRelative(descriptorSetPath, "descriptor set path");
if (!descriptorSetPath.endsWith(".desc") && !descriptorSetPath.endsWith(".binpb")) {
throw new IllegalArgumentException(
"a descriptor set is a serialized FileDescriptorSet; expected a .desc or .binpb path, got '"
+ descriptorSetPath
+ "'");
}
}
private static void requireBuildRelative(String path, String what) {
if (path == null || path.isBlank()) {
throw new IllegalArgumentException(what + " must not be blank");
}
if (path.startsWith("/") || path.contains("..")) {
throw new IllegalArgumentException(
what + " must be a project-relative path without '..'; got '" + path + "'");
}
if (!path.startsWith(BUILD_ROOT)) {
throw new IllegalArgumentException(
what
+ " must be under '"
+ BUILD_ROOT
+ "'; generated sources in a source tree get committed and then edited. Got '"
+ path
+ "'");
}
}
/** This repository's layout. */
public static GrpcCodegenOutput standard() {
return new GrpcCodegenOutput(
"build/generated/source/proto/main/java",
"build/generated/source/proto/main/grpc",
"build/generated/descriptor/stable-schema.desc",
true);
}
}
@@ -0,0 +1,158 @@
package dev.caskeleton.grpc.codegen;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* A previously released consumer, described by exactly what it compiles against.
*
* <p>This is the check a wire-compatibility gate cannot make. A field rename, a service move to a
* new package, or a change to {@code java_package} keeps every serialized message valid and stops
* every generated client compiling — and the only way to notice before the consumer does is to keep
* a record of what the consumer names and compare a candidate schema against it.
*/
public record GrpcConsumerFixture(
String name,
String schemaVersion,
Set<String> requiredServicePaths,
Set<String> requiredMethodPaths,
Set<String> requiredJavaPackages) {
/** What kind of source break a consumer hit. Reported separately, never merged into one count. */
public enum BreakKind {
/** A service the consumer names is gone or moved. */
SERVICE_PATH,
/** A method the consumer calls is gone or renamed. */
METHOD_PATH,
/** The Java package the consumer imports from moved. */
JAVA_PACKAGE
}
/** One thing a consumer needs that a candidate schema no longer provides. */
public record SourceBreak(BreakKind kind, String missing) {
/** Requires both halves. */
public SourceBreak {
if (kind == null || missing == null || missing.isBlank()) {
throw new IllegalArgumentException("a source break names its kind and what went missing");
}
}
}
/** Copies every requirement set. */
public GrpcConsumerFixture {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("a consumer fixture needs a name");
}
if (schemaVersion == null || schemaVersion.isBlank()) {
throw new IllegalArgumentException(
"a consumer fixture names the schema version it was built against");
}
if (requiredServicePaths == null
|| requiredMethodPaths == null
|| requiredJavaPackages == null) {
throw new IllegalArgumentException("a consumer fixture states all three requirement sets");
}
requiredServicePaths = Set.copyOf(requiredServicePaths);
requiredMethodPaths = Set.copyOf(requiredMethodPaths);
requiredJavaPackages = Set.copyOf(requiredJavaPackages);
}
/**
* Derives a fixture from a released consumer's own source.
*
* <p>Read from the consumer rather than declared beside it, because a hand-written requirement
* list is a second copy of what the client already says and the copy is the one that stops being
* updated. Three rules, all mechanical:
*
* <ul>
* <li>a generated Java package is any package a {@code fixture} class imports from;
* <li>a service is a {@code <Name>Grpc} import, mapped back to {@code <package>.<Name>} with
* the generated-package suffix removed;
* <li>a method is a {@code stub.<name>(} call, mapped to {@code <service>/<UpperCamelName>}.
* </ul>
*
* <p>It is an approximation of compiling the fixture, which is what the plan asks for and what
* ADR-GRPC-002 records this repository cannot do yet. It is a useful approximation because it
* fails on exactly the changes a compile would fail on: a renamed method, a moved service, a
* relocated Java package.
*
* @param generatedPackageSuffix the segment appended to a proto package for generated code, e.g.
* {@code generated}; removed to recover the schema's own service path
*/
public static GrpcConsumerFixture fromJavaSource(
String name, String schemaVersion, String javaSource, String generatedPackageSuffix) {
if (javaSource == null || javaSource.isBlank()) {
throw new IllegalArgumentException("a consumer fixture needs its source text");
}
if (generatedPackageSuffix == null || generatedPackageSuffix.isBlank()) {
throw new IllegalArgumentException("the generated package suffix is required");
}
java.util.Set<String> javaPackages = new LinkedHashSet<>();
java.util.Set<String> servicePaths = new LinkedHashSet<>();
java.util.regex.Matcher imports =
java.util.regex.Pattern.compile(
"^\\s*import\\s+([\\w.]+)\\.(\\w+);", java.util.regex.Pattern.MULTILINE)
.matcher(javaSource);
while (imports.find()) {
String importedPackage = imports.group(1);
String importedType = imports.group(2);
if (!importedPackage.endsWith("." + generatedPackageSuffix)) {
continue;
}
javaPackages.add(importedPackage);
if (importedType.endsWith("Grpc")) {
String protoPackage =
importedPackage.substring(
0, importedPackage.length() - generatedPackageSuffix.length() - 1);
servicePaths.add(
protoPackage
+ "."
+ importedType.substring(0, importedType.length() - "Grpc".length()));
}
}
java.util.Set<String> methodPaths = new LinkedHashSet<>();
java.util.regex.Matcher calls =
java.util.regex.Pattern.compile("\\bstub\\.(\\w+)\\s*\\(").matcher(javaSource);
while (calls.find()) {
String method = calls.group(1);
String upperCamel = Character.toUpperCase(method.charAt(0)) + method.substring(1);
servicePaths.forEach(service -> methodPaths.add(service + "/" + upperCamel));
}
if (servicePaths.isEmpty() || methodPaths.isEmpty()) {
throw new IllegalArgumentException(
"no service or method could be derived from '"
+ name
+ "'; a fixture that requires nothing cannot detect a break");
}
return new GrpcConsumerFixture(name, schemaVersion, servicePaths, methodPaths, javaPackages);
}
/**
* What this consumer would fail to compile against {@code candidate}.
*
* @return an empty list when the consumer still compiles
*/
public List<SourceBreak> breaksAgainst(GrpcDescriptorArtifact candidate) {
if (candidate == null) {
throw new IllegalArgumentException("a compatibility check needs a candidate artifact");
}
List<SourceBreak> breaks = new ArrayList<>();
collect(breaks, BreakKind.SERVICE_PATH, requiredServicePaths, candidate.servicePaths());
collect(breaks, BreakKind.METHOD_PATH, requiredMethodPaths, candidate.methodPaths());
collect(
breaks, BreakKind.JAVA_PACKAGE, requiredJavaPackages, candidate.generatedJavaPackages());
return List.copyOf(breaks);
}
private static void collect(
List<SourceBreak> breaks, BreakKind kind, Set<String> required, Set<String> available) {
Set<String> missing = new LinkedHashSet<>(required);
missing.removeAll(available);
missing.stream().sorted().forEach(entry -> breaks.add(new SourceBreak(kind, entry)));
}
}
@@ -0,0 +1,59 @@
package dev.caskeleton.grpc.codegen;
import java.util.Set;
/**
* One immutable release of the schema: what it contains, and the hashes that prove which bytes it
* was built from.
*
* <p>Service and method paths are carried explicitly, not derived on demand, because they are what
* a consumer compatibility check compares. A descriptor that only carries a digest can say
* "something changed" and nothing else, which is the answer least useful to the person who has to
* decide whether to release.
*/
public record GrpcDescriptorArtifact(
String schemaVersion,
String schemaHash,
String descriptorSetDigest,
String bufImageDigest,
String policyVersion,
Set<String> servicePaths,
Set<String> methodPaths,
Set<String> generatedJavaPackages) {
/** Requires an immutable version, three digests and a non-empty surface. */
public GrpcDescriptorArtifact {
if (schemaVersion == null || schemaVersion.isBlank() || schemaVersion.endsWith("-SNAPSHOT")) {
throw new IllegalArgumentException(
"a schema artifact publishes an immutable version; got '" + schemaVersion + "'");
}
requireDigest(schemaHash, "schema hash");
requireDigest(descriptorSetDigest, "descriptor set digest");
requireDigest(bufImageDigest, "buf image digest");
if (policyVersion == null || policyVersion.isBlank()) {
throw new IllegalArgumentException(
"a schema artifact records the policy version it was judged under");
}
if (servicePaths == null || methodPaths == null || generatedJavaPackages == null) {
throw new IllegalArgumentException(
"a schema artifact carries its service, method and package surface");
}
if (methodPaths.isEmpty()) {
throw new IllegalArgumentException("a schema artifact with no methods describes nothing");
}
servicePaths = Set.copyOf(servicePaths);
methodPaths = Set.copyOf(methodPaths);
generatedJavaPackages = Set.copyOf(generatedJavaPackages);
}
private static void requireDigest(String digest, String what) {
if (digest == null || !digest.startsWith("sha256:") || digest.length() < 15) {
throw new IllegalArgumentException(what + " must be 'sha256:<digest>'; got '" + digest + "'");
}
}
/** Whether this artifact is byte-identical to {@code baseline}. */
public boolean unchangedFrom(GrpcSchemaBaseline baseline) {
return baseline.matches(schemaHash);
}
}
@@ -0,0 +1,68 @@
package dev.caskeleton.grpc.codegen;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* Keeps generated Java packages and hand-written ones apart.
*
* <p>An overlap is not a style problem. Generated code is deleted and rewritten on every build, so
* a hand-written class that shares its package is one clean build away from being either
* overwritten or, more often, from making the generator's output unbuildable in a way that reads as
* a compiler bug. Splitting the namespaces makes the ownership of every file legible from its
* package alone.
*/
public record GrpcGeneratedPackagePolicy(
Set<String> handWrittenPackages, Set<String> generatedPackages) {
/** Copies both sets. */
public GrpcGeneratedPackagePolicy {
if (handWrittenPackages == null || generatedPackages == null) {
throw new IllegalArgumentException("both package sets must be present");
}
if (generatedPackages.isEmpty()) {
throw new IllegalArgumentException("a codegen policy names at least one generated package");
}
handWrittenPackages = Set.copyOf(handWrittenPackages);
generatedPackages = Set.copyOf(generatedPackages);
}
/**
* Every overlap between the two namespaces, described.
*
* @return an empty list when the namespaces are disjoint
*/
public List<String> overlaps() {
List<String> overlaps = new ArrayList<>();
for (String generated : generatedPackages) {
for (String handWritten : handWrittenPackages) {
if (generated.equals(handWritten)
|| generated.startsWith(handWritten + ".")
|| handWritten.startsWith(generated + ".")) {
overlaps.add(
"generated package '"
+ generated
+ "' overlaps hand-written package '"
+ handWritten
+ "'");
}
}
}
overlaps.sort(java.util.Comparator.naturalOrder());
return List.copyOf(overlaps);
}
/**
* Fails when the namespaces overlap.
*
* @throws IllegalStateException naming every overlap
*/
public void requireDisjoint() {
List<String> overlaps = overlaps();
if (!overlaps.isEmpty()) {
throw new IllegalStateException(
"generated and hand-written Java packages must be disjoint: " + overlaps);
}
}
}
@@ -0,0 +1,113 @@
package dev.caskeleton.grpc.codegen;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Decides whether a candidate schema may be published, and refuses to let a released version change
* underneath its consumers.
*
* <p>Two rules, and the second is the one that gets skipped in a hurry: a consumer fixture that no
* longer compiles blocks the release. Publishing anyway and telling the consumers is not the same
* thing — by then the artifact exists, somebody has resolved it, and the fix is a new version
* rather than a decision not to make the change.
*/
public final class GrpcSchemaArtifactPublisher {
private final GrpcBufPolicy policy;
private final Map<String, String> publishedHashesByVersion = new LinkedHashMap<>();
/** Binds a publisher to the governance policy it enforces. */
public GrpcSchemaArtifactPublisher(GrpcBufPolicy policy) {
if (policy == null) {
throw new IllegalArgumentException("a publisher needs a Buf policy");
}
this.policy = policy;
}
/** Whether a candidate may be published, and if not, why not. */
public record PublishDecision(boolean allowed, List<String> blockers) {
/** Copies the blocker list. */
public PublishDecision {
if (blockers == null) {
throw new IllegalArgumentException(
"a publish decision lists its blockers, even when empty");
}
blockers = List.copyOf(blockers);
if (allowed && !blockers.isEmpty()) {
throw new IllegalArgumentException("an allowed publish has no blockers");
}
if (!allowed && blockers.isEmpty()) {
throw new IllegalArgumentException("a refused publish says why");
}
}
}
/**
* Evaluates a candidate against the policy and every consumer fixture.
*
* @param fixtures the previously released consumers this schema must keep compiling
*/
public PublishDecision evaluate(
GrpcDescriptorArtifact candidate, List<GrpcConsumerFixture> fixtures) {
if (candidate == null || fixtures == null) {
throw new IllegalArgumentException("evaluation needs a candidate and a fixture list");
}
List<String> blockers = new ArrayList<>();
String alreadyPublished = publishedHashesByVersion.get(candidate.schemaVersion());
if (alreadyPublished != null && !alreadyPublished.equals(candidate.schemaHash())) {
blockers.add(
"version '"
+ candidate.schemaVersion()
+ "' is already published with a different schema hash; a released schema version is "
+ "immutable");
}
if (!policy.breakingCategory().detectsSourceBreak()) {
blockers.add("the active breaking category does not detect source breaks");
}
for (GrpcConsumerFixture fixture : fixtures) {
for (GrpcConsumerFixture.SourceBreak sourceBreak : fixture.breaksAgainst(candidate)) {
blockers.add(
"consumer '"
+ fixture.name()
+ "' ("
+ fixture.schemaVersion()
+ ") loses "
+ sourceBreak.kind()
+ " '"
+ sourceBreak.missing()
+ "'");
}
}
return new PublishDecision(blockers.isEmpty(), blockers);
}
/**
* Records a successful publish.
*
* @throws IllegalStateException when the decision refused it
*/
public void publish(GrpcDescriptorArtifact candidate, PublishDecision decision) {
if (decision == null || !decision.allowed()) {
throw new IllegalStateException(
"refusing to publish '"
+ (candidate == null ? "null" : candidate.schemaVersion())
+ "': "
+ (decision == null ? "no decision" : decision.blockers()));
}
publishedHashesByVersion.put(candidate.schemaVersion(), candidate.schemaHash());
}
/** The hash published for {@code version}, or null when nothing was. */
public String publishedHash(String version) {
return publishedHashesByVersion.get(version);
}
/** The governance policy in force. */
public GrpcBufPolicy policy() {
return policy;
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.grpc.codegen;
import java.time.Instant;
/**
* The released schema a breaking check compares against.
*
* <p>Pinned to a released artifact rather than to a branch, and that is the whole point. Comparing
* against the previous commit answers "did this commit break anything", which is not the question:
* a breaking change introduced two commits ago and refined since then passes every commit-to-commit
* check while being broken against everything that has actually been deployed.
*/
public record GrpcSchemaBaseline(String version, String schemaHash, Instant releasedAt) {
/** Requires an immutable version, a hash and a release moment. */
public GrpcSchemaBaseline {
if (version == null || version.isBlank()) {
throw new IllegalArgumentException("a schema baseline needs a released version");
}
if (version.endsWith("-SNAPSHOT")) {
throw new IllegalArgumentException(
"a breaking baseline must be an immutable release, not '" + version + "'");
}
if (schemaHash == null || !schemaHash.startsWith("sha256:") || schemaHash.length() < 15) {
throw new IllegalArgumentException(
"a schema baseline needs a 'sha256:<digest>' hash; got '" + schemaHash + "'");
}
if (releasedAt == null) {
throw new IllegalArgumentException("a schema baseline needs a release timestamp");
}
}
/** Whether {@code candidateHash} is byte-identical to this baseline. */
public boolean matches(String candidateHash) {
return schemaHash.equals(candidateHash);
}
}
@@ -0,0 +1,88 @@
package dev.caskeleton.grpc.codegen;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Instant;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcBufPolicyTest {
private static final GrpcSchemaBaseline BASELINE =
new GrpcSchemaBaseline(
"1.4.0",
"sha256:0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0",
Instant.parse("2026-08-01T00:00:00Z"));
@Test
@DisplayName("the Stable public API is gated on the FILE breaking category")
void stablePublicApiUsesTheFileCategory() {
GrpcBufPolicy policy = GrpcBufPolicy.stable(BASELINE);
assertThat(policy.breakingCategory()).isEqualTo(GrpcBreakingCategory.FILE);
assertThat(GrpcBreakingCategory.stableGate()).isEqualTo(GrpcBreakingCategory.FILE);
assertThat(policy.breakingCategory().detectsSourceBreak()).isTrue();
assertThat(policy.breakingCategory().detectsFileMove()).isTrue();
}
@Test
@DisplayName("wire-only compatibility may not gate the Stable public API")
void wireOnlyCompatibilityIsRefused() {
assertThatThrownBy(() -> new GrpcBufPolicy(GrpcBreakingCategory.WIRE, true, true, BASELINE))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("stops compiling");
assertThatThrownBy(
() -> new GrpcBufPolicy(GrpcBreakingCategory.WIRE_JSON, true, true, BASELINE))
.isInstanceOf(IllegalArgumentException.class);
assertThat(GrpcBreakingCategory.WIRE.detectsSourceBreak()).isFalse();
assertThat(GrpcBreakingCategory.PACKAGE.detectsFileMove()).isFalse();
}
@Test
@DisplayName("format and lint are not optional")
void formatAndLintAreNotOptional() {
assertThatThrownBy(() -> new GrpcBufPolicy(GrpcBreakingCategory.FILE, false, true, BASELINE))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new GrpcBufPolicy(GrpcBreakingCategory.FILE, true, false, BASELINE))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("the four lifecycle stages are named, and a missing one is reported")
void lifecycleStagesAreNamedAndChecked() {
assertThat(GrpcBufPolicy.requiredTasks())
.containsExactly("bufFormatCheck", "bufLint", "bufBuild", "bufBreaking");
assertThat(GrpcBufPolicy.missingTasks(Set.of("bufFormatCheck", "bufLint", "bufBuild")))
.containsExactly("bufBreaking");
assertThat(GrpcBufPolicy.missingTasks(Set.copyOf(GrpcBufPolicy.requiredTasks()))).isEmpty();
}
@Test
@DisplayName("the breaking baseline is a released version, never a snapshot")
void baselineIsAnImmutableRelease() {
assertThatThrownBy(
() ->
new GrpcSchemaBaseline(
"1.5.0-SNAPSHOT",
"sha256:0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0",
Instant.parse("2026-08-01T00:00:00Z")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("immutable release");
assertThatThrownBy(
() ->
new GrpcSchemaBaseline(
"1.5.0", "not-a-digest", Instant.parse("2026-08-01T00:00:00Z")))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("an unchanged schema hash needs no breaking review")
void unchangedSchemaNeedsNoBreakingReview() {
GrpcBufPolicy policy = GrpcBufPolicy.stable(BASELINE);
assertThat(policy.unchangedFromBaseline(BASELINE.schemaHash())).isTrue();
assertThat(policy.unchangedFromBaseline("sha256:ffffffffffffffffffffffffffffffff")).isFalse();
}
}
@@ -0,0 +1,78 @@
package dev.caskeleton.grpc.codegen;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcCodegenManifestTest {
@Test
@DisplayName("Java code generation has exactly one owner, and a second is refused")
void codeGenerationHasOneOwner() {
GrpcCodegenManifest manifest = GrpcCodegenManifest.caSkeleton();
manifest.requireSoleOwner("gradle-protobuf-plugin");
assertThatThrownBy(() -> manifest.requireSoleOwner("hand-rolled-protoc-exec"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("hand-rolled-protoc-exec");
}
@Test
@DisplayName("generator versions come from the managed platform, never from a literal")
void generatorVersionsComeFromTheManagedPlatform() {
assertThatThrownBy(
() ->
new GrpcCodegenManifest(
"gradle-protobuf-plugin",
"3.25.5",
GrpcCodegenManifest.MANAGED_VERSION_SOURCE,
GrpcCodegenOutput.standard(),
new GrpcGeneratedPackagePolicy(
Set.of("dev.caskeleton"), Set.of("hyeonworks.grpc.common.v1.generated"))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("protobuf version must come from the managed platform");
}
@Test
@DisplayName("generated sources land under build/, never in a source tree")
void generatedSourcesLandUnderBuild() {
assertThatThrownBy(
() -> new GrpcCodegenOutput("src/main/java", "build/g", "build/x.desc", true))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("must be under 'build/'");
assertThatThrownBy(() -> new GrpcCodegenOutput("build/a", "build/b", "build/schema.txt", true))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("FileDescriptorSet");
assertThat(GrpcCodegenOutput.standard().includeSourceInfo()).isTrue();
}
@Test
@DisplayName("a generated package inside a hand-written one fails the manifest")
void generatedAndHandWrittenPackagesMustBeDisjoint() {
GrpcGeneratedPackagePolicy overlapping =
new GrpcGeneratedPackagePolicy(
Set.of("dev.caskeleton"), Set.of("dev.caskeleton.grpc.generated"));
assertThat(overlapping.overlaps()).hasSize(1);
assertThatThrownBy(overlapping::requireDisjoint)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("dev.caskeleton.grpc.generated");
assertThat(
new GrpcGeneratedPackagePolicy(
Set.of("dev.caskeleton"), Set.of("hyeonworks.grpc.common.v1.generated"))
.overlaps())
.isEmpty();
}
@Test
@DisplayName("an overlap is caught in both directions")
void overlapIsSymmetric() {
GrpcGeneratedPackagePolicy generatedIsAnAncestor =
new GrpcGeneratedPackagePolicy(Set.of("hyeonworks.grpc.hand"), Set.of("hyeonworks.grpc"));
assertThat(generatedIsAnAncestor.overlaps()).hasSize(1);
}
}
@@ -0,0 +1,248 @@
package dev.caskeleton.grpc.codegen;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcDescriptorArtifactTest {
private static final String DIGEST_A =
"sha256:0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0";
private static final String DIGEST_B =
"sha256:1122334455667788990011223344556677889900112233445566778899001122";
private static final GrpcSchemaBaseline BASELINE =
new GrpcSchemaBaseline("1.4.0", DIGEST_A, Instant.parse("2026-08-01T00:00:00Z"));
private static GrpcDescriptorArtifact artifact(
String version, String hash, Set<String> methods, Set<String> javaPackages) {
return new GrpcDescriptorArtifact(
version,
hash,
DIGEST_A,
DIGEST_B,
"grpc-stable-policy-1",
Set.of("hyeonworks.document.v1.DocumentService"),
methods,
javaPackages);
}
private static final Set<String> METHODS =
Set.of(
"hyeonworks.document.v1.DocumentService/GetDocument",
"hyeonworks.document.v1.DocumentService/CreateDocument");
private static final Set<String> PACKAGES = Set.of("hyeonworks.document.v1.generated");
private static String resource(String path) {
try (java.io.InputStream stream =
GrpcDescriptorArtifactTest.class.getClassLoader().getResourceAsStream(path)) {
if (stream == null) {
throw new IllegalStateException("missing test resource " + path);
}
return new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
} catch (java.io.IOException e) {
throw new java.io.UncheckedIOException(e);
}
}
@Test
@DisplayName("a schema artifact publishes an immutable version and three digests")
void artifactCarriesAnImmutableVersionAndDigests() {
GrpcDescriptorArtifact candidate = artifact("1.5.0", DIGEST_B, METHODS, PACKAGES);
assertThat(candidate.schemaVersion()).isEqualTo("1.5.0");
assertThat(candidate.unchangedFrom(BASELINE)).isFalse();
assertThat(artifact("1.4.0", DIGEST_A, METHODS, PACKAGES).unchangedFrom(BASELINE)).isTrue();
assertThatThrownBy(() -> artifact("1.5.0-SNAPSHOT", DIGEST_B, METHODS, PACKAGES))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("immutable version");
assertThatThrownBy(() -> artifact("1.5.0", "nope", METHODS, PACKAGES))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("service, method and Java package breaks are reported separately")
void sourceBreaksAreReportedByKind() {
GrpcConsumerFixture consumer =
new GrpcConsumerFixture(
"document-client-v1",
"1.4.0",
Set.of("hyeonworks.document.v1.DocumentService"),
METHODS,
PACKAGES);
GrpcDescriptorArtifact renamedEverything =
new GrpcDescriptorArtifact(
"1.5.0",
DIGEST_B,
DIGEST_A,
DIGEST_B,
"grpc-stable-policy-1",
Set.of("hyeonworks.document.v2.DocumentService"),
Set.of("hyeonworks.document.v2.DocumentService/GetDocument"),
Set.of("hyeonworks.document.v2.generated"));
List<GrpcConsumerFixture.SourceBreak> breaks = consumer.breaksAgainst(renamedEverything);
assertThat(breaks)
.extracting(GrpcConsumerFixture.SourceBreak::kind)
.containsExactlyInAnyOrder(
GrpcConsumerFixture.BreakKind.SERVICE_PATH,
GrpcConsumerFixture.BreakKind.METHOD_PATH,
GrpcConsumerFixture.BreakKind.METHOD_PATH,
GrpcConsumerFixture.BreakKind.JAVA_PACKAGE);
}
@Test
@DisplayName("a consumer that still compiles reports no break")
void compatibleSchemaBreaksNothing() {
GrpcConsumerFixture consumer =
new GrpcConsumerFixture(
"document-client-v1",
"1.4.0",
Set.of("hyeonworks.document.v1.DocumentService"),
METHODS,
PACKAGES);
Set<String> widened =
Set.of(
"hyeonworks.document.v1.DocumentService/GetDocument",
"hyeonworks.document.v1.DocumentService/CreateDocument",
"hyeonworks.document.v1.DocumentService/ArchiveDocument");
assertThat(consumer.breaksAgainst(artifact("1.5.0", DIGEST_B, widened, PACKAGES))).isEmpty();
}
@Test
@DisplayName("the committed v1 consumer fixture states its own requirements")
void theCommittedFixtureStatesItsOwnRequirements() {
GrpcConsumerFixture derived =
GrpcConsumerFixture.fromJavaSource(
"document-client-v1",
"1.4.0",
resource("consumer-fixtures/v1/src/main/java/fixture/DocumentClientFixture.java"),
"generated");
assertThat(derived.requiredServicePaths())
.containsExactly("hyeonworks.document.v1.DocumentService");
assertThat(derived.requiredMethodPaths())
.containsExactlyInAnyOrder(
"hyeonworks.document.v1.DocumentService/GetDocument",
"hyeonworks.document.v1.DocumentService/CreateDocument");
assertThat(derived.requiredJavaPackages()).containsExactly("hyeonworks.document.v1.generated");
}
@Test
@DisplayName("a schema that renames a method the committed fixture calls blocks the release")
void renamingAMethodTheFixtureCallsBlocksTheRelease() {
GrpcConsumerFixture derived =
GrpcConsumerFixture.fromJavaSource(
"document-client-v1",
"1.4.0",
resource("consumer-fixtures/v1/src/main/java/fixture/DocumentClientFixture.java"),
"generated");
GrpcDescriptorArtifact renamed =
new GrpcDescriptorArtifact(
"1.5.0",
DIGEST_B,
DIGEST_A,
DIGEST_B,
"grpc-stable-policy-1",
Set.of("hyeonworks.document.v1.DocumentService"),
Set.of(
"hyeonworks.document.v1.DocumentService/GetDocument",
"hyeonworks.document.v1.DocumentService/CreateDocumentV2"),
Set.of("hyeonworks.document.v1.generated"));
GrpcSchemaArtifactPublisher publisher =
new GrpcSchemaArtifactPublisher(GrpcBufPolicy.stable(BASELINE));
assertThat(publisher.evaluate(renamed, List.of(derived)).blockers())
.anySatisfy(blocker -> assertThat(blocker).contains("CreateDocument"));
}
@Test
@DisplayName("a fixture that requires nothing is refused rather than passing every schema")
void aFixtureThatRequiresNothingIsRefused() {
assertThatThrownBy(
() ->
GrpcConsumerFixture.fromJavaSource(
"empty-client", "1.4.0", "package fixture;\n\nclass Empty {}\n", "generated"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("cannot detect a break");
}
@Test
@DisplayName("the fixture's own build file pins the schema version it was built against")
void theFixturePinsItsSchemaVersion() {
assertThat(resource("consumer-fixtures/v1/build.gradle.kts"))
.contains("grpc-schema-artifact:1.4.0")
.contains("floats to the latest version");
}
@Test
@DisplayName("a consumer fixture failure blocks the release")
void consumerFixtureFailureBlocksTheRelease() {
GrpcSchemaArtifactPublisher publisher =
new GrpcSchemaArtifactPublisher(GrpcBufPolicy.stable(BASELINE));
GrpcConsumerFixture consumer =
new GrpcConsumerFixture(
"document-client-v1",
"1.4.0",
Set.of("hyeonworks.document.v1.DocumentService"),
METHODS,
PACKAGES);
GrpcDescriptorArtifact dropsAMethod =
artifact(
"1.5.0",
DIGEST_B,
Set.of("hyeonworks.document.v1.DocumentService/GetDocument"),
PACKAGES);
GrpcSchemaArtifactPublisher.PublishDecision decision =
publisher.evaluate(dropsAMethod, List.of(consumer));
assertThat(decision.allowed()).isFalse();
assertThat(decision.blockers())
.anySatisfy(
blocker ->
assertThat(blocker).contains("document-client-v1").contains("CreateDocument"));
assertThatThrownBy(() -> publisher.publish(dropsAMethod, decision))
.isInstanceOf(IllegalStateException.class);
}
@Test
@DisplayName("a released version may not be republished with different bytes")
void releasedVersionsAreImmutable() {
GrpcSchemaArtifactPublisher publisher =
new GrpcSchemaArtifactPublisher(GrpcBufPolicy.stable(BASELINE));
GrpcDescriptorArtifact first = artifact("1.5.0", DIGEST_B, METHODS, PACKAGES);
GrpcSchemaArtifactPublisher.PublishDecision allowed = publisher.evaluate(first, List.of());
assertThat(allowed.allowed()).isTrue();
publisher.publish(first, allowed);
assertThat(publisher.publishedHash("1.5.0")).isEqualTo(DIGEST_B);
GrpcDescriptorArtifact sameVersionDifferentBytes =
artifact("1.5.0", DIGEST_A, METHODS, PACKAGES);
assertThat(publisher.evaluate(sameVersionDifferentBytes, List.of()).blockers())
.anySatisfy(blocker -> assertThat(blocker).contains("immutable"));
}
@Test
@DisplayName("republishing identical bytes under the same version is allowed")
void identicalRepublishIsAllowed() {
GrpcSchemaArtifactPublisher publisher =
new GrpcSchemaArtifactPublisher(GrpcBufPolicy.stable(BASELINE));
GrpcDescriptorArtifact artifact = artifact("1.5.0", DIGEST_B, METHODS, PACKAGES);
publisher.publish(artifact, publisher.evaluate(artifact, List.of()));
assertThat(publisher.evaluate(artifact, List.of()).allowed()).isTrue();
assertThat(publisher.policy().breakingCategory()).isEqualTo(GrpcBreakingCategory.FILE);
}
}
@@ -0,0 +1,17 @@
// The v1 consumer fixture's own build file, kept as a test resource.
//
// It is not part of this repository's Gradle build. Its purpose is to record what a released
// consumer actually depended on — the schema artifact version and nothing else — so that when
// protoc generation is switched on (ADR-GRPC-002) this directory can be compiled as-is against a
// candidate schema, which is the check GrpcConsumerFixture currently approximates from source text.
plugins {
`java-library`
}
dependencies {
// The released schema artifact, pinned. A fixture that floats to the latest version cannot
// detect a break, because it is always built against the schema it is meant to be testing.
implementation("dev.caskeleton.grpc:grpc-schema-artifact:1.4.0")
implementation("io.grpc:grpc-stub")
implementation("io.grpc:grpc-protobuf")
}
@@ -0,0 +1,33 @@
package fixture;
import hyeonworks.document.v1.generated.CreateDocumentRequest;
import hyeonworks.document.v1.generated.DocumentServiceGrpc;
import hyeonworks.document.v1.generated.GetDocumentRequest;
/**
* A previously released consumer, kept as a fixture rather than as a dependency.
*
* <p>This file is a test resource, not a compiled source. It records exactly what the v1 client
* named — the generated package it imported from, the service class it referenced and the methods it
* called — so a candidate schema can be checked against a real client's surface instead of against a
* requirement list somebody typed by hand and will forget to update.
*
* <p>{@code GrpcConsumerFixture.fromJavaSource} derives those three sets from this text. Adding a
* call here widens what a release must keep; that is the intent.
*/
public final class DocumentClientFixture {
private final DocumentServiceGrpc.DocumentServiceBlockingStub stub;
public DocumentClientFixture(DocumentServiceGrpc.DocumentServiceBlockingStub stub) {
this.stub = stub;
}
public String read(String documentId) {
return stub.getDocument(GetDocumentRequest.newBuilder().setId(documentId).build()).getTitle();
}
public String create(String title) {
return stub.createDocument(CreateDocumentRequest.newBuilder().setTitle(title).build()).getId();
}
}
+11
View File
@@ -0,0 +1,11 @@
apply plugin: 'java-library'
// The platform's port layer: identifiers, method policy, execution evidence, failure model,
// deadline primitives and request context.
//
// No dependencies at all, and that is the contract rather than an accident. The Stable plan's
// Global Constraints make `grpc-core-api` framework-free so "evidence and policy do not know about
// a transport" is verifiable instead of aspirational — the same rule `messaging-core-api` holds.
// A type here may not name io.grpc, Spring, Netty, protobuf or a database.
dependencies {
}
+84
View File
@@ -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,55 @@
package dev.caskeleton.grpc.context;
/**
* Who is calling, as established by authentication — never as claimed by a header.
*
* <p>There is no factory that takes raw metadata, and that absence is the design. A tenant id read
* from an inbound header is an assertion by the caller; treating it as identity is how one tenant
* reads another's data. The only way to build this type is to name the authentication source that
* verified it.
*/
public record GrpcClientIdentity(String actorId, String tenantId, String authenticationSource) {
/** Requires bounded values and a named verifier. */
public GrpcClientIdentity {
requireBounded(actorId, "actor id");
requireBounded(tenantId, "tenant id");
requireBounded(authenticationSource, "authentication source");
}
private static void requireBounded(String value, String what) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(what + " must not be blank");
}
if (value.length() > 128) {
throw new IllegalArgumentException(what + " must be at most 128 characters");
}
for (int index = 0; index < value.length(); index++) {
if (Character.isISOControl(value.charAt(index))) {
throw new IllegalArgumentException(what + " must not contain a control character");
}
}
}
/**
* The only constructor a transport is allowed to reach.
*
* @param authenticationSource the verifier that established this identity — a token issuer, an
* mTLS peer certificate subject, an internal service credential. Naming it is what makes an
* audit able to answer "on what basis did we believe this".
*/
public static GrpcClientIdentity fromVerifiedAuthentication(
String actorId, String tenantId, String authenticationSource) {
return new GrpcClientIdentity(actorId, tenantId, authenticationSource);
}
/**
* The anonymous caller, for methods that allow one.
*
* <p>Explicit rather than null, so "no identity" and "identity not yet resolved" are different
* states in the code that reads them.
*/
public static GrpcClientIdentity anonymous() {
return new GrpcClientIdentity("anonymous", "public", "anonymous-access-policy");
}
}
@@ -0,0 +1,66 @@
package dev.caskeleton.grpc.context;
import java.util.Map;
/**
* Two budgets, not one: the transport's hard total, and the softer allowance for keys this
* application defines.
*
* <p>They are separate because they fail differently. Exceeding the hard total is a transport
* rejection that arrives as an opaque {@code RESOURCE_EXHAUSTED} from the framework with no
* indication of which header was to blame; exceeding the user-defined allowance is this platform's
* own decision, made before the call starts, with the offending key in the message. Merging them
* would mean discovering the first kind at the point where only the second kind is fixable.
*/
public record GrpcMetadataBudget(int maxTotalBytes, int maxUserDefinedBytes, int maxEntries) {
/** Rejects an incoherent budget. */
public GrpcMetadataBudget {
if (maxTotalBytes <= 0 || maxUserDefinedBytes <= 0 || maxEntries <= 0) {
throw new IllegalArgumentException("every metadata budget bound must be positive");
}
if (maxUserDefinedBytes > maxTotalBytes) {
throw new IllegalArgumentException(
"user-defined allowance "
+ maxUserDefinedBytes
+ " cannot exceed the hard total "
+ maxTotalBytes);
}
}
/** The platform default: 8 KiB total, 4 KiB of it user-defined, at most 32 entries. */
public static GrpcMetadataBudget standard() {
return new GrpcMetadataBudget(8192, 4096, 32);
}
/**
* Checks a proposed metadata set before the call starts.
*
* @throws IllegalArgumentException naming the bound that was exceeded
*/
public void check(Map<GrpcMetadataKey, String> metadata) {
if (metadata == null) {
throw new IllegalArgumentException("metadata must not be null");
}
if (metadata.size() > maxEntries) {
throw new IllegalArgumentException(
"metadata has " + metadata.size() + " entries; the budget allows " + maxEntries);
}
int userDefinedBytes = 0;
for (Map.Entry<GrpcMetadataKey, String> entry : metadata.entrySet()) {
String value = entry.getValue();
if (value == null) {
throw new IllegalArgumentException(
"metadata key '" + entry.getKey().name() + "' has a null value");
}
userDefinedBytes += entry.getKey().name().length() + value.length();
}
if (userDefinedBytes > maxUserDefinedBytes) {
throw new IllegalArgumentException(
"user-defined metadata is "
+ userDefinedBytes
+ " bytes; the budget allows "
+ maxUserDefinedBytes);
}
}
}
@@ -0,0 +1,65 @@
package dev.caskeleton.grpc.context;
import java.util.regex.Pattern;
/**
* A metadata key the platform is willing to carry.
*
* <p>The {@code grpc-} prefix is refused outright: it is reserved by the protocol, and a
* user-defined key that borrows it is a key whose meaning changes when the library is upgraded. The
* {@code -bin} suffix rule is not cosmetic either — gRPC decides base64 handling from it, so a
* binary key without the suffix is a value that arrives corrupted rather than one that arrives
* mislabelled.
*/
public record GrpcMetadataKey(String name, Kind kind) {
/** Whether the value travels as ASCII text or as base64-encoded bytes. */
public enum Kind {
/** Printable ASCII value. */
ASCII,
/** Binary value; the key must end in {@code -bin}. */
BINARY
}
private static final Pattern CANONICAL = Pattern.compile("[a-z0-9]([a-z0-9._-]*[a-z0-9])?");
private static final String BINARY_SUFFIX = "-bin";
private static final String RESERVED_PREFIX = "grpc-";
/** Validates the name against the protocol's rules and this platform's allowlist shape. */
public GrpcMetadataKey {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("metadata key must not be blank");
}
if (kind == null) {
throw new IllegalArgumentException("metadata key needs a kind");
}
if (name.length() > 64) {
throw new IllegalArgumentException("metadata key must be at most 64 characters: " + name);
}
if (!CANONICAL.matcher(name).matches()) {
throw new IllegalArgumentException(
"metadata key must be lowercase and use only [a-z0-9._-]: " + name);
}
if (name.startsWith(RESERVED_PREFIX)) {
throw new IllegalArgumentException(
"metadata key '" + name + "' uses the reserved 'grpc-' prefix");
}
boolean binarySuffix = name.endsWith(BINARY_SUFFIX);
if (kind == Kind.BINARY && !binarySuffix) {
throw new IllegalArgumentException("binary metadata key must end in '-bin': " + name);
}
if (kind == Kind.ASCII && binarySuffix) {
throw new IllegalArgumentException("ASCII metadata key must not end in '-bin': " + name);
}
}
/** An ASCII key. */
public static GrpcMetadataKey ascii(String name) {
return new GrpcMetadataKey(name, Kind.ASCII);
}
/** A binary key. */
public static GrpcMetadataKey binary(String name) {
return new GrpcMetadataKey(name, Kind.BINARY);
}
}
@@ -0,0 +1,87 @@
package dev.caskeleton.grpc.context;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.core.RpcType;
import dev.caskeleton.grpc.deadline.GrpcCancellationToken;
import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* What an application use case is handed when a gRPC call reaches it.
*
* <p>Notice what is not here: no {@code Metadata}, no credentials, no headers map. The metadata
* that survives is the allowlisted subset, keyed by a validated {@link GrpcMetadataKey}, and the
* allowlist is supplied at construction rather than consulted from a global — so a test can state
* the allowlist it means and a second transport cannot quietly widen it.
*/
public record GrpcRequestContext(
GrpcMethodName method,
RpcType rpcType,
GrpcClientIdentity identity,
GrpcDeadlineBudget deadline,
GrpcCancellationToken cancellation,
Map<GrpcMetadataKey, String> metadata,
Optional<String> traceId) {
/** Copies the metadata map so the context cannot change after the call has started. */
public GrpcRequestContext {
if (method == null
|| rpcType == null
|| identity == null
|| deadline == null
|| cancellation == null
|| metadata == null
|| traceId == null) {
throw new IllegalArgumentException("request context fields must all be present");
}
metadata = Map.copyOf(metadata);
}
/**
* Builds a context from inbound metadata, keeping only allowlisted keys.
*
* <p>Unknown keys are refused rather than dropped. Silently dropping is worse: the caller
* believes it sent a correlation id, the server never sees one, and nothing anywhere says so.
*
* @throws IllegalArgumentException when a key is outside {@code allowlist}, or the budget is
* exceeded
*/
public static GrpcRequestContext create(
GrpcMethodName method,
RpcType rpcType,
GrpcClientIdentity identity,
GrpcDeadlineBudget deadline,
GrpcCancellationToken cancellation,
Map<GrpcMetadataKey, String> inboundMetadata,
Set<GrpcMetadataKey> allowlist,
GrpcMetadataBudget budget,
String traceId) {
if (inboundMetadata == null || allowlist == null || budget == null) {
throw new IllegalArgumentException("metadata, allowlist and budget must all be present");
}
Map<GrpcMetadataKey, String> accepted = new LinkedHashMap<>();
for (Map.Entry<GrpcMetadataKey, String> entry : inboundMetadata.entrySet()) {
if (!allowlist.contains(entry.getKey())) {
throw new IllegalArgumentException(
"metadata key '" + entry.getKey().name() + "' is not on this method's allowlist");
}
accepted.put(entry.getKey(), entry.getValue());
}
budget.check(accepted);
return new GrpcRequestContext(
method, rpcType, identity, deadline, cancellation, accepted, Optional.ofNullable(traceId));
}
/** An allowlisted metadata value, if the caller sent one. */
public Optional<String> metadataValue(GrpcMetadataKey key) {
return Optional.ofNullable(metadata.get(key));
}
/** Whether the call is still worth doing work for. */
public boolean live() {
return !cancellation.cancelled() && !deadline.expired();
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.grpc.core;
import java.util.regex.Pattern;
/**
* The logical name of a client channel profile — {@code document-service-read}, not a host.
*
* <p>A channel is named for the traffic it carries rather than for where it points, because the
* same backend reached with a long-stream SLO and with a short-unary SLO is two channels. Naming
* them both after the service is what makes that distinction disappear.
*/
public record GrpcChannelProfileName(String value) {
private static final Pattern CANONICAL = Pattern.compile("[a-z][a-z0-9]*(-[a-z0-9]+)*");
/** Validates the canonical lower-kebab form. */
public GrpcChannelProfileName {
GrpcIdentifiers.requireBounded(value, "channel profile name");
if (!CANONICAL.matcher(value).matches()) {
throw new IllegalArgumentException(
"channel profile name must be lower-kebab-case; got '" + value + "'");
}
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.grpc.core;
/**
* Shared validation for the platform's bounded identifiers.
*
* <p>Package-private machinery rather than a public helper: the rule that an identifier rejects
* blanks and control characters belongs to the identifiers, and a public utility would invite
* callers to validate a string and then carry it as a {@code String} anyway — which is the thing
* the identifier types exist to stop.
*/
final class GrpcIdentifiers {
private GrpcIdentifiers() {}
/** Longest identifier this platform accepts, in characters. */
static final int MAX_LENGTH = 256;
/**
* Returns {@code value} when it is a well-formed identifier segment.
*
* @throws IllegalArgumentException when it is null, blank, over-long, or carries a control
* character — including the ones that would forge a log line or a metadata frame
*/
static String requireBounded(String value, String what) {
if (value == null) {
throw new IllegalArgumentException(what + " must not be null");
}
if (value.isBlank()) {
throw new IllegalArgumentException(what + " must not be blank");
}
if (value.length() > MAX_LENGTH) {
throw new IllegalArgumentException(
what + " must be at most " + MAX_LENGTH + " characters; got " + value.length());
}
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
if (Character.isISOControl(character)) {
throw new IllegalArgumentException(
what + " must not contain a control character at index " + index);
}
if (Character.isWhitespace(character)) {
throw new IllegalArgumentException(what + " must not contain whitespace at index " + index);
}
}
return value;
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.grpc.core;
import java.util.regex.Pattern;
/**
* The canonical full method name, {@code package.Service/Method}.
*
* <p>This is the identity every other contract keys on: the policy catalog, the descriptor, the
* operation ledger, the retry decision and the observation convention all have to agree on one
* spelling of a method, and this type is that spelling. It is deliberately the same string gRPC
* itself uses for a full method name, so a descriptor and a catalog cannot disagree about which
* method a policy covers.
*/
public record GrpcMethodName(GrpcServiceName service, String method) {
private static final Pattern METHOD = Pattern.compile("[A-Z][A-Za-z0-9_]*");
/** Validates the method half; the service half validates itself. */
public GrpcMethodName {
if (service == null) {
throw new IllegalArgumentException("method name requires a service");
}
GrpcIdentifiers.requireBounded(method, "method name");
if (!METHOD.matcher(method).matches()) {
throw new IllegalArgumentException(
"method must be an UpperCamelCase proto method name; got '" + method + "'");
}
}
/**
* Parses {@code package.Service/Method}.
*
* @throws IllegalArgumentException when the canonical separator is missing or repeated — a name
* with two slashes is not a method name with a slash in it, it is a different string
*/
public static GrpcMethodName parse(String canonical) {
GrpcIdentifiers.requireBounded(canonical, "full method name");
int separator = canonical.indexOf('/');
if (separator < 0) {
throw new IllegalArgumentException(
"full method name must be 'package.Service/Method'; got '" + canonical + "'");
}
if (canonical.indexOf('/', separator + 1) >= 0) {
throw new IllegalArgumentException(
"full method name must contain exactly one '/'; got '" + canonical + "'");
}
return new GrpcMethodName(
new GrpcServiceName(canonical.substring(0, separator)), canonical.substring(separator + 1));
}
/** The canonical {@code package.Service/Method} form. */
public String canonical() {
return service.value() + "/" + method;
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.grpc.core;
import java.util.regex.Pattern;
/**
* A fully qualified Protobuf service name — {@code package.subpackage.ServiceName}.
*
* <p>Bounded on purpose. Every downstream consumer of an identifier in this platform — the policy
* catalog, the descriptor, the observability convention, the admin snapshot — assumes the name is
* canonical, and the only place that assumption can be established is where the name is made.
*/
public record GrpcServiceName(String value) {
private static final Pattern CANONICAL =
Pattern.compile("[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*\\.[A-Z][A-Za-z0-9_]*");
/** Validates the canonical form. */
public GrpcServiceName {
GrpcIdentifiers.requireBounded(value, "service name");
if (!CANONICAL.matcher(value).matches()) {
throw new IllegalArgumentException(
"service name must be a fully qualified proto name 'package.Service'; got '"
+ value
+ "'");
}
}
/** The proto package this service is declared in. */
public String protoPackage() {
return value.substring(0, value.lastIndexOf('.'));
}
/** The unqualified service name. */
public String simpleName() {
return value.substring(value.lastIndexOf('.') + 1);
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.grpc.core;
import java.util.Set;
/**
* Build-time invariants the Stable platform holds about its own module graph.
*
* <p>These are asserted from Java as well as from Gradle on purpose. The registry gate ({@code
* verifyCleanArchitectureDependencies}) is the one that fails a build, and it is the authority;
* this class is what a runtime component calls when it is handed a dependency set it did not
* compile against — a starter reading its own classpath, say — where the Gradle gate has nothing to
* look at.
*/
public final class GrpcStableBuildInvariant {
private GrpcStableBuildInvariant() {}
/**
* Whether a Stable module may depend on an Advanced one.
*
* <p>Always false, and it takes no argument for a reason: the answer does not vary by module, by
* capability or by environment. A method that could return true for some input would be the seam
* through which "just this one Advanced type in the starter" arrives.
*/
public static boolean advancedDependencyAllowed() {
return false;
}
/**
* Fails when a Stable module's dependency set reaches an Advanced module.
*
* @throws IllegalStateException naming every advanced dependency found
*/
public static void requireNoAdvancedDependency(String moduleId, Set<String> dependencies) {
GrpcIdentifiers.requireBounded(moduleId, "module id");
if (dependencies == null) {
throw new IllegalArgumentException("dependency set must not be null");
}
if (!GrpcStableModuleCatalog.standard().isStable(moduleId)) {
return;
}
Set<String> leaks = GrpcStableModuleCatalog.advancedLeaks(dependencies);
if (!leaks.isEmpty()) {
throw new IllegalStateException(
"Stable module '"
+ moduleId
+ "' must not depend on advanced modules "
+ leaks.stream().sorted().toList()
+ "; advanced capabilities are reached through an explicit feature flag, never by "
+ "being on the Stable classpath.");
}
}
}
@@ -0,0 +1,79 @@
package dev.caskeleton.grpc.core;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* The exact set of Stable platform modules, and the exact set that is not Stable.
*
* <p>The Stable plan fixes its module list as a Global Constraint so that "this capability is
* Stable" is a fact with one owner instead of a property of whatever happens to be on a classpath.
* This repository's module registry ({@code src/config/architecture/modules.json}) is the SSOT for
* which Gradle projects exist; this catalog is the SSOT for which of them the Stable contract
* covers, and {@code GrpcStableModuleCatalogTest} holds the two together.
*
* <p>The names are this repository's leaf ids rather than the plan's, because the plan's four
* testkit modules are four strict test lanes here — see the adaptation design, §2.
*/
public record GrpcStableModuleCatalog(Set<String> modules) {
private static final Set<String> STABLE =
Set.of(
"grpc-core-api",
"grpc-proto-contract",
"grpc-codegen",
"grpc-policy",
"grpc-server",
"grpc-client",
"grpc-discovery",
"grpc-admin",
"grpc-observability",
"grpc-operation-ledger-jpa",
"grpc-spring-boot-starter",
"grpc-testkit");
private static final Set<String> ADVANCED =
Set.of(
"grpc-advanced-bootstrap",
"grpc-advanced-edition",
"grpc-advanced-streaming",
"grpc-advanced-resilience",
"grpc-advanced-compat",
"grpc-advanced-diagnostics");
/** Defensively copies, so a caller cannot widen the Stable set by mutating what it was given. */
public GrpcStableModuleCatalog {
if (modules == null || modules.isEmpty()) {
throw new IllegalArgumentException("a module catalog needs at least one module");
}
modules = Set.copyOf(modules);
}
/** The Stable module set. */
public static GrpcStableModuleCatalog standard() {
return new GrpcStableModuleCatalog(STABLE);
}
/** The Advanced and Experimental module set, which is disjoint from {@link #standard()}. */
public static Set<String> advancedModules() {
return ADVANCED;
}
/** Whether {@code moduleId} is part of the Stable contract. */
public boolean isStable(String moduleId) {
return modules.contains(moduleId);
}
/**
* The advanced module ids that appear in {@code candidateDependencies}.
*
* <p>Returned rather than thrown so a build or startup check can name every violation at once. A
* gate that reports the first advanced dependency it finds makes removing four of them a
* four-round conversation.
*/
public static Set<String> advancedLeaks(Set<String> candidateDependencies) {
Set<String> leaks = new LinkedHashSet<>(candidateDependencies);
leaks.retainAll(ADVANCED);
return Set.copyOf(leaks);
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.grpc.core;
/**
* The canonical gRPC status codes, mirrored so that {@code grpc-core-api} stays free of io.grpc.
*
* <p>The mirror is the point. The Stable plan requires the evidence and failure model to be
* framework-free, and a failure context that names {@code io.grpc.Status} would put the transport
* inside the contract that exists to describe what the transport did. {@code grpc-policy} owns the
* translation in both directions and is tested against the real enum.
*/
public enum GrpcStatusCode {
OK(0),
CANCELLED(1),
UNKNOWN(2),
INVALID_ARGUMENT(3),
DEADLINE_EXCEEDED(4),
NOT_FOUND(5),
ALREADY_EXISTS(6),
PERMISSION_DENIED(7),
RESOURCE_EXHAUSTED(8),
FAILED_PRECONDITION(9),
ABORTED(10),
OUT_OF_RANGE(11),
UNIMPLEMENTED(12),
INTERNAL(13),
UNAVAILABLE(14),
DATA_LOSS(15),
UNAUTHENTICATED(16);
private final int value;
GrpcStatusCode(int value) {
this.value = value;
}
/** The wire value, identical to {@code io.grpc.Status.Code#value()}. */
public int value() {
return value;
}
/**
* Whether this code, on its own, says the server never began the work.
*
* <p>Only {@link #UNAVAILABLE} at connect time and {@link #CANCELLED} before send have that
* meaning, and neither is decidable from the code alone — which is why this returns false for
* every code. It exists so a caller that wants to ask "is this safe to replay" is pushed to the
* evidence model instead of reading safety out of a status.
*/
public boolean provesNotStarted() {
return false;
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.grpc.core;
/**
* The four gRPC method shapes.
*
* <p>Only {@link #UNARY} and {@link #SERVER_STREAMING} are Stable. Client and bidirectional
* streaming are Advanced capabilities: they exist in this enum because a descriptor can name them
* and a policy has to be able to refuse them, not because the Stable platform serves them.
*/
public enum RpcType {
/** One request, one response. */
UNARY(false, false),
/** One request, a bounded stream of responses. */
SERVER_STREAMING(false, true),
/** A stream of requests, one response. Advanced. */
CLIENT_STREAMING(true, false),
/** Streams in both directions. Advanced. */
BIDI_STREAMING(true, true);
private final boolean clientStreaming;
private final boolean serverStreaming;
RpcType(boolean clientStreaming, boolean serverStreaming) {
this.clientStreaming = clientStreaming;
this.serverStreaming = serverStreaming;
}
/** Whether the client sends more than one message. */
public boolean clientStreaming() {
return clientStreaming;
}
/** Whether the server sends more than one message. */
public boolean serverStreaming() {
return serverStreaming;
}
/** Whether either direction streams. */
public boolean streaming() {
return clientStreaming || serverStreaming;
}
/**
* Whether the Stable platform serves this shape.
*
* <p>The Stable scope is Unary and Server Streaming. A Stable component that is handed anything
* else must refuse it rather than degrade: partially supporting client streaming is how a
* platform ends up with a delivery guarantee nobody wrote down.
*/
public boolean stable() {
return !clientStreaming;
}
}
@@ -0,0 +1,68 @@
package dev.caskeleton.grpc.deadline;
import java.time.Instant;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
/**
* An idempotent cancellation signal that remembers why it first fired.
*
* <p>"First reason wins" is the contract that makes a cancellation report usable. A call cancelled
* by its client and then, a millisecond later, by a shutdown drain has one cause and one useful
* story; a token that keeps the last write tells the second one.
*/
public final class GrpcCancellationToken {
private final AtomicReference<Cancellation> cancellation = new AtomicReference<>();
/** The reason and moment a token fired. */
public record Cancellation(String reason, Instant at) {
/** Requires a bounded reason and a moment. */
public Cancellation {
if (reason == null || reason.isBlank() || reason.length() > 128) {
throw new IllegalArgumentException(
"cancellation reason must be a bounded non-blank string");
}
if (at == null) {
throw new IllegalArgumentException("cancellation needs a timestamp");
}
}
}
/**
* Cancels once. Later calls are accepted and ignored.
*
* @return true when this call is the one that cancelled the token
*/
public boolean cancel(String reason, Instant at) {
return cancellation.compareAndSet(null, new Cancellation(reason, at));
}
/** Whether the token has fired. */
public boolean cancelled() {
return cancellation.get() != null;
}
/** The first cancellation, if any. */
public Optional<Cancellation> cancellation() {
return Optional.ofNullable(cancellation.get());
}
/**
* Fails when the token has already fired.
*
* <p>Called before starting a new external side effect. The Stable plan forbids beginning one
* after cancellation, and the only way to hold that is to ask at the moment of starting.
*/
public void requireNotCancelled(String operation) {
Cancellation current = cancellation.get();
if (current != null) {
throw new IllegalStateException(
"refusing to start '"
+ operation
+ "': the call was cancelled ("
+ current.reason()
+ ")");
}
}
}
@@ -0,0 +1,85 @@
package dev.caskeleton.grpc.deadline;
import java.time.Duration;
/**
* How much time a call actually has, after the parent deadline and the method profile have both had
* their say.
*
* <p>The rule is "shorter of the two, minus the reserve", and it is here rather than at each call
* site because the failure mode of getting it wrong is silent: a downstream timeout longer than the
* inbound deadline never fires, and the caller sees a deadline the server has no idea it missed.
*/
public record GrpcDeadlineBudget(Duration remaining, GrpcDeadlineProfile profile) {
/** Rejects a budget with no time in it. */
public GrpcDeadlineBudget {
if (remaining == null || profile == null) {
throw new IllegalArgumentException("a deadline budget needs a remaining time and a profile");
}
if (remaining.isNegative()) {
throw new IllegalArgumentException("remaining time must not be negative; got " + remaining);
}
}
/**
* The budget for a Stable unary entry point, which must have a positive deadline.
*
* @throws IllegalArgumentException when there is no parent deadline at all — the Stable plan
* requires every unary method to carry one, and defaulting here is how a call ends up with an
* infinite deadline nobody chose
*/
public static GrpcDeadlineBudget forEntryPoint(
Duration parentRemaining, GrpcDeadlineProfile profile) {
if (profile == null) {
throw new IllegalArgumentException("a deadline budget needs a profile");
}
if (parentRemaining == null) {
throw new IllegalArgumentException(
"a Stable unary call requires a positive deadline; none was propagated");
}
if (parentRemaining.isZero() || parentRemaining.isNegative()) {
throw new IllegalArgumentException(
"a Stable unary call requires a positive deadline; got " + parentRemaining);
}
Duration bounded =
parentRemaining.compareTo(profile.total()) <= 0 ? parentRemaining : profile.total();
Duration afterReserve = bounded.minus(profile.safetyMargin());
return new GrpcDeadlineBudget(
afterReserve.isNegative() ? Duration.ZERO : afterReserve, profile);
}
/**
* Derives the budget for a downstream dependency call under {@code downstream}.
*
* <p>Takes the shorter of what is left here and what the downstream profile allows, then
* subtracts the downstream reserve. A dependency can never be given more time than its caller
* has.
*/
public GrpcDeadlineBudget deriveDownstream(GrpcDeadlineProfile downstream) {
if (downstream == null) {
throw new IllegalArgumentException("a downstream budget needs a profile");
}
Duration bounded =
remaining.compareTo(downstream.total()) <= 0 ? remaining : downstream.total();
Duration afterReserve = bounded.minus(downstream.safetyMargin());
return new GrpcDeadlineBudget(
afterReserve.isNegative() ? Duration.ZERO : afterReserve, downstream);
}
/**
* Whether there is enough time left to be worth starting a dependency call.
*
* <p>Starting one with less is not merely wasteful: it produces a deadline failure on the
* dependency that looks identical to the dependency being slow, and the resulting incident
* investigates the wrong system.
*/
public boolean canStartDependencyCall() {
return remaining.compareTo(profile.minimumAttemptBudget()) >= 0 && !remaining.isZero();
}
/** Whether any time is left at all. */
public boolean expired() {
return remaining.isZero() || remaining.isNegative();
}
}
@@ -0,0 +1,26 @@
package dev.caskeleton.grpc.deadline;
import dev.caskeleton.grpc.error.GrpcFailureContext;
import dev.caskeleton.grpc.error.GrpcPlatformException;
/**
* A deadline elapsed.
*
* <p>Its own type rather than a generic platform exception because callers branch on it, and
* because it is the failure most often misread: for a mutation it is a statement about time, not
* about the outcome. {@link #requiresReconciliation()} answers the question the status code cannot.
*/
public class GrpcDeadlineExceededException extends GrpcPlatformException {
private static final long serialVersionUID = 1L;
/** Wraps a deadline failure context. */
public GrpcDeadlineExceededException(GrpcFailureContext context) {
super(context);
}
/** Wraps a deadline failure context, preserving the underlying cause. */
public GrpcDeadlineExceededException(GrpcFailureContext context, Throwable cause) {
super(context, cause);
}
}
@@ -0,0 +1,59 @@
package dev.caskeleton.grpc.deadline;
import java.time.Duration;
/**
* A method's deadline shape: how long it gets, how much is held back, and the smallest slice worth
* starting a dependency call with.
*
* <p>The safety margin is not decoration. A downstream call handed the parent's entire remaining
* time has no room left to serialize a response or write trailers, so the caller's deadline fires
* while the answer is in flight — which produces exactly the {@code COMPLETION_UNKNOWN} the
* platform then has to reconcile.
*/
public record GrpcDeadlineProfile(
Duration total, Duration safetyMargin, Duration minimumAttemptBudget) {
/** Rejects a profile that cannot leave usable time behind. */
public GrpcDeadlineProfile {
if (total == null || safetyMargin == null || minimumAttemptBudget == null) {
throw new IllegalArgumentException("a deadline profile needs all three durations");
}
if (total.isZero() || total.isNegative()) {
throw new IllegalArgumentException("total deadline must be positive; got " + total);
}
if (safetyMargin.isNegative()) {
throw new IllegalArgumentException("safety margin must not be negative");
}
if (minimumAttemptBudget.isNegative()) {
throw new IllegalArgumentException("minimum attempt budget must not be negative");
}
if (safetyMargin.compareTo(total) >= 0) {
throw new IllegalArgumentException(
"safety margin " + safetyMargin + " leaves no time inside total " + total);
}
if (minimumAttemptBudget.compareTo(total.minus(safetyMargin)) > 0) {
throw new IllegalArgumentException(
"minimum attempt budget "
+ minimumAttemptBudget
+ " exceeds the usable window "
+ total.minus(safetyMargin));
}
}
/**
* A profile with the platform's default reserves: a tenth of the total held back for response
* serialization and trailers, and a twentieth as the smallest attempt worth starting.
*/
public static GrpcDeadlineProfile of(Duration total) {
if (total == null || total.isZero() || total.isNegative()) {
throw new IllegalArgumentException("total deadline must be positive");
}
return new GrpcDeadlineProfile(total, total.dividedBy(10), total.dividedBy(20));
}
/** Total minus the reserve — the time actually available to the call. */
public Duration usable() {
return total.minus(safetyMargin);
}
}
@@ -0,0 +1,69 @@
package dev.caskeleton.grpc.error;
import dev.caskeleton.grpc.core.GrpcStatusCode;
import dev.caskeleton.grpc.evidence.GrpcBusinessEvidence;
import dev.caskeleton.grpc.evidence.GrpcExecutionEvidence;
import dev.caskeleton.grpc.evidence.GrpcStreamEvidence;
/**
* What the caller may conclude about the business operation.
*
* <p>Separate from the status code because the interesting case has no status of its own: a
* mutation that times out is {@code DEADLINE_EXCEEDED} on the wire and {@link #COMPLETION_UNKNOWN}
* in the business, and a caller that reads the first as the second's answer either loses a
* committed write or performs it twice.
*/
public enum GrpcCompletionOutcome {
/** The operation completed and the caller knows it. */
COMPLETED,
/** The operation did not run; nothing changed. */
REJECTED,
/** The operation may or may not have completed. Requires a status query or reconciliation. */
COMPLETION_UNKNOWN,
/** A stream delivered a prefix and then ended. */
PARTIAL_STREAM;
/**
* Derives the outcome from a status and the evidence, for a call that changes state.
*
* <p>{@code DEADLINE_EXCEEDED} on a mutation defaults to {@link #COMPLETION_UNKNOWN} rather than
* to a failure, and {@code UNAVAILABLE} does the same unless the client watched the send fail.
* Those two defaults are the plan's Global Constraints, in the one place that can apply them.
*/
public static GrpcCompletionOutcome forMutation(
GrpcStatusCode statusCode, GrpcExecutionEvidence evidence) {
if (statusCode == null || evidence == null) {
throw new IllegalArgumentException("outcome derivation needs a status and evidence");
}
if (evidence.business() == GrpcBusinessEvidence.COMMIT_CONFIRMED) {
return COMPLETED;
}
if (evidence.stream() instanceof GrpcStreamEvidence.Partial) {
return PARTIAL_STREAM;
}
if (statusCode == GrpcStatusCode.OK) {
return COMPLETED;
}
if (evidence.transport().provesNotStarted()) {
return REJECTED;
}
return switch (statusCode) {
case DEADLINE_EXCEEDED, UNAVAILABLE, CANCELLED, UNKNOWN, INTERNAL -> COMPLETION_UNKNOWN;
case INVALID_ARGUMENT,
UNAUTHENTICATED,
PERMISSION_DENIED,
NOT_FOUND,
FAILED_PRECONDITION,
OUT_OF_RANGE,
UNIMPLEMENTED,
RESOURCE_EXHAUSTED ->
REJECTED;
case OK, ALREADY_EXISTS, ABORTED, DATA_LOSS -> COMPLETION_UNKNOWN;
};
}
/** Whether the caller must resolve the result before acting on it. */
public boolean requiresReconciliation() {
return this == COMPLETION_UNKNOWN;
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.grpc.error;
import dev.caskeleton.grpc.core.GrpcStatusCode;
/**
* The platform's stable failure taxonomy, and the canonical status each category maps to.
*
* <p>The mapping lives on the category rather than in a switch somewhere in the server, because the
* client needs the same table to interpret what it received. A category whose status is decided at
* the mapping site has a different answer on each side of the wire.
*/
public enum GrpcFailureCategory {
/** Malformed request; the transport validator refused it. */
VALIDATION(GrpcStatusCode.INVALID_ARGUMENT, false),
/** No verified caller identity. */
AUTHENTICATION(GrpcStatusCode.UNAUTHENTICATED, false),
/** A verified caller without the required permission. */
AUTHORIZATION(GrpcStatusCode.PERMISSION_DENIED, false),
/** The addressed resource does not exist. */
NOT_FOUND(GrpcStatusCode.NOT_FOUND, false),
/** A uniqueness or version conflict the caller can resolve. */
CONFLICT(GrpcStatusCode.ABORTED, true),
/** The resource is in a state that forbids the operation. */
PRECONDITION(GrpcStatusCode.FAILED_PRECONDITION, false),
/** A quota, rate limit or admission bound was hit. */
RESOURCE_EXHAUSTED(GrpcStatusCode.RESOURCE_EXHAUSTED, true),
/** The deadline elapsed. Says nothing about whether the work completed. */
DEADLINE(GrpcStatusCode.DEADLINE_EXCEEDED, false),
/** The caller or the platform cancelled the call. */
CANCELLED(GrpcStatusCode.CANCELLED, false),
/** The dependency could not be reached, or refused a connection. */
TRANSIENT_DEPENDENCY(GrpcStatusCode.UNAVAILABLE, true),
/** The dependency answered with a failure that retrying will reproduce. */
PERMANENT_DEPENDENCY(GrpcStatusCode.INTERNAL, false),
/** The method exists in the schema but is not served. */
UNIMPLEMENTED(GrpcStatusCode.UNIMPLEMENTED, false),
/** Stored state is inconsistent with what the contract guarantees. */
DATA_INTEGRITY(GrpcStatusCode.DATA_LOSS, false),
/** Anything unclassified. Never carries provider detail to the client. */
INTERNAL(GrpcStatusCode.INTERNAL, false);
private final GrpcStatusCode statusCode;
private final boolean transientFailure;
GrpcFailureCategory(GrpcStatusCode statusCode, boolean transientFailure) {
this.statusCode = statusCode;
this.transientFailure = transientFailure;
}
/** The wire status this category maps to. */
public GrpcStatusCode statusCode() {
return statusCode;
}
/**
* Whether the same call, later, might succeed.
*
* <p>Necessary for a retry, never sufficient: {@code TRANSIENT_DEPENDENCY} on a non-idempotent
* mutation is still not retryable, and that decision belongs to the retry coordinator.
*/
public boolean transientFailure() {
return transientFailure;
}
}
@@ -0,0 +1,99 @@
package dev.caskeleton.grpc.error;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.core.GrpcStatusCode;
import dev.caskeleton.grpc.evidence.GrpcExecutionEvidence;
import java.time.Duration;
import java.util.Optional;
/**
* Everything the platform is willing to say about a failed attempt.
*
* <p>The field list is a closed allowlist rather than a convenience bag. Every field here is
* bounded and safe to log; a payload, a metadata map, a token or an idempotency key has no
* component to live in, so redaction is a property of the type rather than of whoever writes the
* log line. The provider exception survives as {@code cause} on {@link GrpcPlatformException},
* where it reaches an operator without reaching a client.
*/
public record GrpcFailureContext(
GrpcMethodName method,
GrpcStatusCode statusCode,
GrpcFailureCategory category,
GrpcExecutionEvidence evidence,
GrpcCompletionOutcome completionOutcome,
RetryDisposition retryDisposition,
int attempt,
Duration elapsed,
Optional<String> traceId) {
/**
* What the platform decided about retrying this attempt, and why it is not re-derivable later.
*/
public enum RetryDisposition {
/** The attempt may be retried under the method's retry policy. */
RETRYABLE,
/** The attempt must not be retried; repeating it risks a duplicate effect. */
NOT_RETRYABLE,
/** Retrying is pointless: the same call will fail the same way. */
TERMINAL,
/** The result is unknown; resolve it with a status query before deciding. */
RESOLVE_FIRST
}
/** Validates bounds and rejects a trace id that is not safe to log. */
public GrpcFailureContext {
if (method == null
|| statusCode == null
|| category == null
|| evidence == null
|| completionOutcome == null
|| retryDisposition == null
|| elapsed == null
|| traceId == null) {
throw new IllegalArgumentException("failure context fields must all be present");
}
if (attempt < 1) {
throw new IllegalArgumentException("attempt is 1-based; got " + attempt);
}
if (elapsed.isNegative()) {
throw new IllegalArgumentException("elapsed must not be negative");
}
if (!method.equals(evidence.method())) {
throw new IllegalArgumentException(
"failure context method '"
+ method.canonical()
+ "' disagrees with its evidence '"
+ evidence.method().canonical()
+ "'");
}
traceId.ifPresent(
value -> {
if (value.isBlank() || value.length() > 128) {
throw new IllegalArgumentException("trace id must be a bounded non-blank identifier");
}
});
}
/**
* A one-line summary safe to put in an exception message, a log line or an operator report.
*
* <p>Built here rather than at each call site so there is exactly one answer to "what may this
* failure say out loud".
*/
public String redactedSummary() {
return method.canonical()
+ " failed with "
+ statusCode
+ " ("
+ category
+ "), outcome="
+ completionOutcome
+ ", retry="
+ retryDisposition
+ ", attempt="
+ attempt
+ ", elapsedMs="
+ elapsed.toMillis()
+ traceId.map(id -> ", trace=" + id).orElse("");
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.grpc.error;
/**
* The platform's failure carrier.
*
* <p>Its message is {@link GrpcFailureContext#redactedSummary()} and nothing else, so the string
* that ends up in a log or in a status description cannot accidentally be a driver message
* containing a SQL state, a host or a token. The original exception is kept as the cause, which is
* where an operator reads it and a client does not.
*/
public class GrpcPlatformException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient GrpcFailureContext context;
/** Wraps a failure context. */
public GrpcPlatformException(GrpcFailureContext context) {
this(context, null);
}
/** Wraps a failure context, preserving the provider exception as the cause. */
public GrpcPlatformException(GrpcFailureContext context, Throwable cause) {
super(requireContext(context).redactedSummary(), cause);
this.context = context;
}
private static GrpcFailureContext requireContext(GrpcFailureContext context) {
if (context == null) {
throw new IllegalArgumentException("a platform exception requires a failure context");
}
return context;
}
/** The structured failure this exception carries. */
public GrpcFailureContext context() {
return context;
}
/** Whether the caller must resolve the business result before acting. */
public boolean requiresReconciliation() {
return context.completionOutcome().requiresReconciliation();
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.grpc.evidence;
/**
* What is known about the business effect — the second evidence axis.
*
* <p>Kept separate from the transport axis because the two genuinely disagree in the case that
* matters: a mutation whose transaction committed and whose response was lost is {@link
* #COMMIT_CONFIRMED} at the server and {@link #COMMIT_UNKNOWN} at the client, and no status code
* distinguishes those from a mutation that never ran.
*/
public enum GrpcBusinessEvidence {
/** No business work was attempted. */
NONE,
/** The server accepted the call and began application work. */
ATTEMPTED,
/** The application confirmed a durable commit. */
COMMIT_CONFIRMED,
/** Work may or may not have committed. This is a real state, not a placeholder. */
COMMIT_UNKNOWN,
/** The application refused the request; nothing was committed. */
REJECTED;
/**
* Whether a retry may run the business operation again without an idempotency guard.
*
* <p>{@link #COMMIT_UNKNOWN} answers false — which is the whole reason the value exists.
*/
public boolean safeToRepeatWithoutGuard() {
return this == NONE || this == REJECTED;
}
}
@@ -0,0 +1,87 @@
package dev.caskeleton.grpc.evidence;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.core.RpcType;
/**
* The three evidence axes for one RPC attempt, held together and never collapsed.
*
* <p>The same type is used by the failure model and by the observation convention. That is
* deliberate: when the exception and the metric are built from different snapshots of what
* happened, the incident review has two accounts of one call and no way to choose between them.
*/
public record GrpcExecutionEvidence(
GrpcMethodName method,
RpcType rpcType,
GrpcTransportEvidence transport,
GrpcBusinessEvidence business,
GrpcStreamEvidence stream) {
/** Rejects combinations that cannot have been observed. */
public GrpcExecutionEvidence {
if (method == null) {
throw new IllegalArgumentException("evidence requires a method");
}
if (rpcType == null || transport == null || business == null || stream == null) {
throw new IllegalArgumentException("every evidence axis must be present");
}
if (rpcType == RpcType.UNARY && stream.delivered()) {
throw new IllegalArgumentException(
"a unary call cannot carry stream evidence; got " + stream);
}
if (transport == GrpcTransportEvidence.NOT_SENT && business != GrpcBusinessEvidence.NONE) {
throw new IllegalArgumentException(
"a request the client never sent cannot carry business evidence " + business);
}
}
/** A not-yet-started attempt. */
public static GrpcExecutionEvidence notStarted(GrpcMethodName method, RpcType rpcType) {
return new GrpcExecutionEvidence(
method,
rpcType,
GrpcTransportEvidence.NOT_SENT,
GrpcBusinessEvidence.NONE,
GrpcStreamEvidence.none());
}
/**
* Records that response headers arrived, leaving the business axis exactly as it was.
*
* <p>This is the promotion the plan forbids, written as the one method that is allowed to observe
* headers — so the forbidden edit is visible as a change to this method rather than as a
* plausible line somewhere in an interceptor.
*/
public GrpcExecutionEvidence withResponseHeadersSeen() {
return new GrpcExecutionEvidence(
method, rpcType, GrpcTransportEvidence.RESPONSE_HEADERS_SEEN, business, stream);
}
/** Records a confirmed commit, which only the application may assert. */
public GrpcExecutionEvidence withCommitConfirmed() {
return new GrpcExecutionEvidence(
method, rpcType, transport, GrpcBusinessEvidence.COMMIT_CONFIRMED, stream);
}
/** Records that the outcome of the business work is unknown. */
public GrpcExecutionEvidence withCommitUnknown() {
return new GrpcExecutionEvidence(
method, rpcType, transport, GrpcBusinessEvidence.COMMIT_UNKNOWN, stream);
}
/** Replaces the stream axis. */
public GrpcExecutionEvidence withStream(GrpcStreamEvidence replacement) {
return new GrpcExecutionEvidence(method, rpcType, transport, business, replacement);
}
/**
* Whether a whole-call transparent retry is permitted by the evidence alone.
*
* <p>Evidence alone is never sufficient — the idempotency profile and the retry owner also have a
* say — but it is sufficient to refuse, and refusing here keeps the three reasons to refuse from
* having to be re-derived at every call site.
*/
public boolean permitsWholeCallRetry() {
return !stream.delivered() && business.safeToRepeatWithoutGuard();
}
}
@@ -0,0 +1,80 @@
package dev.caskeleton.grpc.evidence;
/**
* What a stream delivered — the third evidence axis.
*
* <p>A sealed hierarchy rather than an enum because three of the four states carry a position, and
* a position is the only thing that makes stream evidence actionable: "partial" without a last
* sequence cannot be resumed and cannot be reconciled, so it degrades to "something happened".
*/
public sealed interface GrpcStreamEvidence {
/** No stream was involved. The only legal value for a unary call. */
record None() implements GrpcStreamEvidence {}
/**
* The stream delivered messages up to {@code lastSequence} and then ended without completing.
*
* <p>Its presence forbids a whole-call transparent retry: the client already saw a prefix, and
* replaying the call would deliver that prefix twice.
*/
record Partial(long lastSequence) implements GrpcStreamEvidence {
/** Rejects a sequence that cannot have been observed. */
public Partial {
if (lastSequence < 0) {
throw new IllegalArgumentException("last sequence must not be negative");
}
}
}
/**
* The consumer acknowledged applying messages up to {@code lastAppliedSequence}.
*
* <p>Requires a named acknowledgement source. The Stable plan forbids synthesising this state
* from transport progress, and a required non-blank source is what makes that forgery visible:
* there is nowhere to put "the writer returned from onNext".
*/
record Applied(long lastAppliedSequence, String acknowledgementSource)
implements GrpcStreamEvidence {
/** Requires a real acknowledgement source. */
public Applied {
if (lastAppliedSequence < 0) {
throw new IllegalArgumentException("last applied sequence must not be negative");
}
if (acknowledgementSource == null || acknowledgementSource.isBlank()) {
throw new IllegalArgumentException(
"APPLIED requires a named application acknowledgement source; transport progress is "
+ "not an acknowledgement");
}
}
}
/**
* The stream ended at {@code lastSequence} and can be continued with {@code
* resumeTokenReference}.
*
* <p>The reference is an opaque handle, never the token itself: a resume token carries a signed
* actor, tenant and filter fingerprint, and evidence is a thing that gets logged.
*/
record Resumable(long lastSequence, String resumeTokenReference) implements GrpcStreamEvidence {
/** Requires a position and an opaque handle. */
public Resumable {
if (lastSequence < 0) {
throw new IllegalArgumentException("last sequence must not be negative");
}
if (resumeTokenReference == null || resumeTokenReference.isBlank()) {
throw new IllegalArgumentException("a resumable stream needs a resume token reference");
}
}
}
/** The canonical "no stream" value. */
static GrpcStreamEvidence none() {
return new None();
}
/** Whether any message was delivered, which is what forbids a whole-call retry. */
default boolean delivered() {
return !(this instanceof None);
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.grpc.evidence;
/**
* What the transport was observed to do — the first of the three independent evidence axes.
*
* <p>Every value here is an observation, never an inference. The distinction that matters is
* between {@link #NOT_SENT}, which means the client watched the send fail, and {@link #UNOBSERVED},
* which means nobody knows. Collapsing the second into the first is the mistake this axis exists to
* prevent: it turns "we do not know whether the server got the request" into "the server did not
* get the request", and that is the reasoning that replays a payment.
*/
public enum GrpcTransportEvidence {
/** The client observed the send fail before any byte reached the wire. */
NOT_SENT,
/** Bytes left the client. Nothing is known about what the server did with them. */
SENT_UNCONFIRMED,
/** Response headers arrived. This says the server started; it does not say it committed. */
RESPONSE_HEADERS_SEEN,
/** At least one response message arrived. */
RESPONSE_MESSAGE_SEEN,
/** Trailers arrived, so the call completed at the transport level. */
TRAILERS_SEEN,
/** The call ended without the client observing enough to place it above. */
UNOBSERVED;
/**
* Whether the client saw the server begin producing a response.
*
* <p>Deliberately not named anything with "success" or "committed" in it. Response headers are a
* transport fact, and the only business meaning they carry is the one the business axis records
* separately.
*/
public boolean serverResponded() {
return this == RESPONSE_HEADERS_SEEN || this == RESPONSE_MESSAGE_SEEN || this == TRAILERS_SEEN;
}
/** Whether this observation, alone, rules out the server having started the work. */
public boolean provesNotStarted() {
return this == NOT_SENT;
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.grpc.ledger;
import dev.caskeleton.grpc.core.GrpcMethodName;
/**
* What makes two requests the same operation.
*
* <p>All three parts are load-bearing. Without the caller fingerprint, one tenant's idempotency key
* suppresses another tenant's write. Without the method, the same key reused across two operations
* makes the second one a replay of the first. Without the key, there is no operation identity at
* all.
*
* <p>The key is stored as a hash, never in the clear: an idempotency key is chosen by the caller
* and is frequently an order id, an invoice number or something else that identifies a person.
*/
public record GrpcOperationIdentity(
String callerFingerprint, GrpcMethodName method, String idempotencyKeyHash) {
/** Requires all three parts, with the key already hashed. */
public GrpcOperationIdentity {
if (callerFingerprint == null || callerFingerprint.isBlank()) {
throw new IllegalArgumentException(
"an operation identity needs a caller fingerprint; without one, a key from one tenant "
+ "suppresses another tenant's write");
}
if (method == null) {
throw new IllegalArgumentException("an operation identity names its method");
}
if (idempotencyKeyHash == null || !idempotencyKeyHash.startsWith("sha256:")) {
throw new IllegalArgumentException(
"the idempotency key is stored hashed; got '" + idempotencyKeyHash + "'");
}
}
/** The stable storage key: the three parts joined, safe to index and safe to log. */
public String storageKey() {
return callerFingerprint + "|" + method.canonical() + "|" + idempotencyKeyHash;
}
}
@@ -0,0 +1,50 @@
package dev.caskeleton.grpc.ledger;
import java.time.Instant;
import java.util.Optional;
/**
* The durable claim store behind idempotent mutations.
*
* <p>A port in the API leaf rather than an interface next to its JPA implementation, so the policy
* layer can require durable idempotency without depending on a database. The contract that matters
* is on {@link #claim}: it must be atomic against a unique constraint, because the two concurrent
* attempts this exists to separate arrive at the same microsecond.
*/
public interface GrpcOperationLedger {
/**
* Claims {@code identity}, or returns the claim that already exists.
*
* <p>Must be a single atomic insert-or-read against a unique constraint. A read-then-insert
* implementation admits both concurrent attempts, which is the exact failure the ledger exists to
* prevent, and it does so only under the load where it matters.
*
* @return the existing record when one was already claimed, empty when this call created it
*/
Optional<GrpcOperationLedgerRecord> claim(
GrpcOperationIdentity identity, String requestFingerprint, Instant now);
/** The record for {@code identity}, if there is one. */
Optional<GrpcOperationLedgerRecord> find(GrpcOperationIdentity identity);
/**
* Marks a claim committed with a replayable outcome.
*
* <p>Called inside the business transaction wherever the datastore allows it. When the ledger and
* the mutation commit separately, there is a window in which the mutation is durable and the
* claim is not, and a retry in that window runs the mutation twice.
*/
void markCommitted(GrpcOperationIdentity identity, String outcomeReference, Instant now);
/** Marks a claim terminally failed. */
void markFailed(GrpcOperationIdentity identity, Instant now);
/**
* Releases a claim that will never complete, so the caller may try again.
*
* <p>Only for a claim whose owner is known to be gone. Releasing one whose owner is merely slow
* re-admits the duplicate.
*/
void release(GrpcOperationIdentity identity);
}
@@ -0,0 +1,59 @@
package dev.caskeleton.grpc.ledger;
import java.time.Instant;
import java.util.Optional;
/**
* One durable operation claim, as the policy layer sees it.
*
* <p>The request fingerprint is what turns the ledger from a deduplicator into a correctness check.
* A caller that reuses a key for a different request has made a mistake, and returning the first
* request's answer would hide it; comparing fingerprints turns that into a {@code
* FAILED_PRECONDITION} the caller can act on.
*/
public record GrpcOperationLedgerRecord(
GrpcOperationIdentity identity,
GrpcOperationLedgerState state,
String requestFingerprint,
Optional<String> outcomeReference,
Instant claimedAt,
Optional<Instant> completedAt) {
/** Requires an identity, a state, a fingerprint and a claim moment. */
public GrpcOperationLedgerRecord {
if (identity == null || state == null) {
throw new IllegalArgumentException("a ledger record needs an identity and a state");
}
if (requestFingerprint == null || !requestFingerprint.startsWith("sha256:")) {
throw new IllegalArgumentException("a ledger record stores a hashed request fingerprint");
}
if (claimedAt == null || outcomeReference == null || completedAt == null) {
throw new IllegalArgumentException("a ledger record needs its timestamps and Optionals");
}
if (state.terminal() && completedAt.isEmpty()) {
throw new IllegalArgumentException("a terminal ledger record records when it finished");
}
if (state == GrpcOperationLedgerState.COMMITTED && outcomeReference.isEmpty()) {
throw new IllegalArgumentException(
"a COMMITTED record without an outcome reference cannot be replayed, which is the only "
+ "reason to record a commit");
}
}
/** A fresh claim. */
public static GrpcOperationLedgerRecord claim(
GrpcOperationIdentity identity, String requestFingerprint, Instant claimedAt) {
return new GrpcOperationLedgerRecord(
identity,
GrpcOperationLedgerState.IN_PROGRESS,
requestFingerprint,
Optional.empty(),
claimedAt,
Optional.empty());
}
/** Whether {@code candidateFingerprint} is the same request this claim was made for. */
public boolean sameRequestAs(String candidateFingerprint) {
return requestFingerprint.equals(candidateFingerprint);
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.grpc.ledger;
/**
* Where a durable operation claim is in its life.
*
* <p>Three states, and the first is the one that carries the weight: {@link #IN_PROGRESS} is a
* durable record that somebody started this exact operation and has not finished. Without it, two
* concurrent attempts with the same idempotency key both find nothing and both run.
*/
public enum GrpcOperationLedgerState {
/** Claimed and running. A duplicate arriving now must wait, poll or be refused. */
IN_PROGRESS(false),
/** Committed, with a replayable outcome. */
COMMITTED(true),
/** Failed in a way that will not succeed on repetition. */
FAILED_TERMINAL(true);
private final boolean terminal;
GrpcOperationLedgerState(boolean terminal) {
this.terminal = terminal;
}
/** Whether the operation has finished, successfully or not. */
public boolean terminal() {
return terminal;
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.grpc.policy;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.core.RpcType;
import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile;
/**
* Everything the platform decides per method, in one immutable value.
*
* <p>The contradictions are rejected here rather than at the point of use. A method declared
* non-idempotent with an explicit retry policy is not a runtime edge case to handle; it is a
* configuration that should never have been constructible, and the compact constructor is where
* that is true.
*/
public record GrpcMethodPolicy(
GrpcMethodName method,
RpcType rpcType,
RpcIdempotencyProfile idempotency,
GrpcDeadlineProfile deadline,
WaitForReadyPolicy waitForReady,
boolean explicitRetryEnabled,
int maxInboundMessageBytes,
int maxOutboundMessageBytes) {
/** Rejects every combination the Stable plan forbids. */
public GrpcMethodPolicy {
if (method == null || rpcType == null || idempotency == null || waitForReady == null) {
throw new IllegalArgumentException("method policy fields must all be present");
}
if (deadline == null) {
throw new IllegalArgumentException(
"method '"
+ method.canonical()
+ "' has no deadline profile; every Stable method needs one");
}
if (maxInboundMessageBytes <= 0 || maxOutboundMessageBytes <= 0) {
throw new IllegalArgumentException("message size limits must be positive");
}
// Streaming is checked first because both rules fire for a streaming method and only one of
// them explains why: "it is STREAMING" restates the declaration, while "a retry would redeliver
// a prefix the client already saw" is the reason the declaration exists.
if (rpcType.streaming() && explicitRetryEnabled) {
throw new IllegalArgumentException(
"method '"
+ method.canonical()
+ "' streams; a whole-call retry would redeliver a prefix the client already saw");
}
if (explicitRetryEnabled && !idempotency.explicitRetryAllowed()) {
throw new IllegalArgumentException(
"method '"
+ method.canonical()
+ "' is "
+ idempotency
+ " and must not carry an explicit retry policy");
}
if (idempotency == RpcIdempotencyProfile.STREAMING && !rpcType.streaming()) {
throw new IllegalArgumentException(
"method '" + method.canonical() + "' is unary but declares the STREAMING profile");
}
if (waitForReady.queues() && deadline.total().isZero()) {
throw new IllegalArgumentException(
"method '"
+ method.canonical()
+ "' enables wait-for-ready without a deadline to bound it");
}
}
/** A read-only unary method with the platform's default reserves. */
public static GrpcMethodPolicy readOnlyUnary(
GrpcMethodName method, GrpcDeadlineProfile deadline) {
return new GrpcMethodPolicy(
method,
RpcType.UNARY,
RpcIdempotencyProfile.READ_ONLY,
deadline,
WaitForReadyPolicy.DISABLED,
true,
4 * 1024 * 1024,
4 * 1024 * 1024);
}
/** A state-changing unary method that refuses explicit retry. */
public static GrpcMethodPolicy nonIdempotentUnary(
GrpcMethodName method, GrpcDeadlineProfile deadline) {
return new GrpcMethodPolicy(
method,
RpcType.UNARY,
RpcIdempotencyProfile.NON_IDEMPOTENT,
deadline,
WaitForReadyPolicy.DISABLED,
false,
4 * 1024 * 1024,
4 * 1024 * 1024);
}
/** Whether this method may be hedged. */
public boolean hedgingAllowed() {
return rpcType == RpcType.UNARY && idempotency.hedgingAllowed();
}
}
@@ -0,0 +1,124 @@
package dev.caskeleton.grpc.policy;
import dev.caskeleton.grpc.core.GrpcMethodName;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* The immutable method-policy lookup, built once and then only read.
*
* <p>Its most useful property is the one that fails a build: when a descriptor method set is
* declared, registering a policy for a method the schema does not have is an error. That catches
* the rename — the method becomes {@code CreateDocumentV2}, the policy still names {@code
* CreateDocument}, and every call to the new method silently runs with default deadline, default
* retry and no idempotency requirement.
*/
public final class GrpcMethodPolicyCatalog {
private final Map<GrpcMethodName, GrpcMethodPolicy> policies;
private GrpcMethodPolicyCatalog(Map<GrpcMethodName, GrpcMethodPolicy> policies) {
this.policies = Map.copyOf(policies);
}
/** A new builder. */
public static Builder builder() {
return new Builder();
}
/** The policy for {@code method}, if one is registered. */
public Optional<GrpcMethodPolicy> find(GrpcMethodName method) {
return Optional.ofNullable(policies.get(method));
}
/**
* The policy for {@code method}.
*
* @throws IllegalStateException when none is registered — a call with no policy has no deadline
* and no idempotency profile, and serving it would mean inventing both
*/
public GrpcMethodPolicy require(GrpcMethodName method) {
GrpcMethodPolicy policy = policies.get(method);
if (policy == null) {
throw new IllegalStateException(
"no method policy registered for '"
+ method.canonical()
+ "'; a Stable method without a policy has no deadline and no idempotency profile");
}
return policy;
}
/** Every registered method. */
public Set<GrpcMethodName> methods() {
return policies.keySet();
}
/** How many methods carry a policy. */
public int size() {
return policies.size();
}
/** Accumulates policies and validates the set as a whole at {@link #build()}. */
public static final class Builder {
private final Map<GrpcMethodName, GrpcMethodPolicy> policies = new LinkedHashMap<>();
private final Set<GrpcMethodName> descriptorMethods = new LinkedHashSet<>();
private boolean descriptorDeclared;
private Builder() {}
/**
* Declares the methods the compiled schema actually contains.
*
* <p>Optional, and the catalog is materially weaker without it: without a descriptor there is
* nothing to compare a policy's method name against.
*/
public Builder withDescriptorMethods(Set<GrpcMethodName> methods) {
if (methods == null || methods.isEmpty()) {
throw new IllegalArgumentException("a descriptor method set must not be empty");
}
descriptorMethods.addAll(methods);
descriptorDeclared = true;
return this;
}
/**
* Registers one method policy.
*
* @throws IllegalArgumentException on a duplicate registration — two policies for one method
* means the effective policy depends on registration order
*/
public Builder register(GrpcMethodPolicy policy) {
if (policy == null) {
throw new IllegalArgumentException("policy must not be null");
}
GrpcMethodPolicy previous = policies.putIfAbsent(policy.method(), policy);
if (previous != null) {
throw new IllegalArgumentException(
"duplicate method policy for '" + policy.method().canonical() + "'");
}
return this;
}
/**
* Builds the catalog.
*
* @throws IllegalStateException when a registered method is absent from the declared descriptor
*/
public GrpcMethodPolicyCatalog build() {
if (descriptorDeclared) {
Set<GrpcMethodName> unknown = new LinkedHashSet<>(policies.keySet());
unknown.removeAll(descriptorMethods);
if (!unknown.isEmpty()) {
throw new IllegalStateException(
"method policies reference methods the schema does not declare: "
+ unknown.stream().map(GrpcMethodName::canonical).sorted().toList());
}
}
return new GrpcMethodPolicyCatalog(policies);
}
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.grpc.policy;
/**
* How safe a method is to run more than once — the property every retry decision starts from.
*
* <p>Idempotency is a property of the operation, not of the failure, and keeping it on the method
* policy rather than deriving it from a status is what stops "UNAVAILABLE means retry" from being
* applied to a transfer.
*/
public enum RpcIdempotencyProfile {
/** Reads nothing changes. Retryable and the only profile eligible for hedging. */
READ_ONLY(true, true, false),
/** Repeating the write reaches the same end state — a set-to-value, a delete-by-id. */
NATURALLY_IDEMPOTENT(true, false, false),
/** Safe to repeat only with a caller-supplied key and a durable ledger behind it. */
IDEMPOTENCY_KEY_REQUIRED(true, false, true),
/** Safe to repeat only when a precondition — a version, an ETag — is carried and checked. */
CONDITIONALLY_IDEMPOTENT(true, false, false),
/** Repeating it does it twice. No explicit retry, no hedging, ever. */
NON_IDEMPOTENT(false, false, false),
/** A stream. Whole-call retry is meaningless once a prefix has been delivered. */
STREAMING(false, false, false);
private final boolean explicitRetryAllowed;
private final boolean hedgingAllowed;
private final boolean idempotencyKeyRequired;
RpcIdempotencyProfile(
boolean explicitRetryAllowed, boolean hedgingAllowed, boolean idempotencyKeyRequired) {
this.explicitRetryAllowed = explicitRetryAllowed;
this.hedgingAllowed = hedgingAllowed;
this.idempotencyKeyRequired = idempotencyKeyRequired;
}
/** Whether an explicit retry policy may be attached to a method with this profile. */
public boolean explicitRetryAllowed() {
return explicitRetryAllowed;
}
/** Whether duplicate in-flight attempts may be issued. Only reads qualify. */
public boolean hedgingAllowed() {
return hedgingAllowed;
}
/** Whether the caller must supply an idempotency key for the call to be accepted. */
public boolean idempotencyKeyRequired() {
return idempotencyKeyRequired;
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.grpc.policy;
/**
* Whether a call queues while the channel is disconnected, instead of failing fast.
*
* <p>Default off. Wait-for-ready turns a fast {@code UNAVAILABLE} into a wait that ends at the
* deadline, which is the right trade for a batch worker and the wrong one for a request a person is
* watching — the user gets the whole deadline as latency and then an error anyway.
*/
public enum WaitForReadyPolicy {
/** Fail fast when the channel is not ready. The default for every method. */
DISABLED,
/** A worker or batch path may queue inside its deadline and queue budget. */
WORKER_OPT_IN,
/** A user-synchronous path that has been explicitly reviewed and approved to queue. */
APPROVED_SYNCHRONOUS;
/** Whether calls under this policy queue rather than fail fast. */
public boolean queues() {
return this != DISABLED;
}
}
@@ -0,0 +1,174 @@
package dev.caskeleton.grpc.context;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.core.RpcType;
import dev.caskeleton.grpc.deadline.GrpcCancellationToken;
import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget;
import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcMetadataBudgetTest {
private static final GrpcMethodName GET =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
private static final GrpcMetadataKey CORRELATION = GrpcMetadataKey.ascii("x-correlation-id");
private static final GrpcMetadataKey IDEMPOTENCY = GrpcMetadataKey.ascii("x-idempotency-key");
private static GrpcDeadlineBudget budget() {
return GrpcDeadlineBudget.forEntryPoint(
Duration.ofSeconds(1), GrpcDeadlineProfile.of(Duration.ofSeconds(2)));
}
@Test
@DisplayName("a metadata key may not borrow the reserved grpc- prefix")
void reservedPrefixIsRefused() {
assertThatThrownBy(() -> GrpcMetadataKey.ascii("grpc-tenant"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("reserved");
}
@Test
@DisplayName("binary keys end in -bin and ASCII keys do not")
void binarySuffixMatchesTheKind() {
assertThat(GrpcMetadataKey.binary("trace-state-bin").kind())
.isEqualTo(GrpcMetadataKey.Kind.BINARY);
assertThatThrownBy(() -> GrpcMetadataKey.binary("trace-state"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> GrpcMetadataKey.ascii("trace-state-bin"))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("uppercase and control-character keys are refused")
void nonCanonicalKeysAreRefused() {
assertThatThrownBy(() -> GrpcMetadataKey.ascii("X-Correlation-Id"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> GrpcMetadataKey.ascii(""))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("the user-defined allowance is separate from, and never exceeds, the hard total")
void userDefinedAllowanceIsSeparateFromTheHardTotal() {
GrpcMetadataBudget standard = GrpcMetadataBudget.standard();
assertThat(standard.maxUserDefinedBytes()).isLessThan(standard.maxTotalBytes());
assertThatThrownBy(() -> new GrpcMetadataBudget(1024, 2048, 8))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("cannot exceed the hard total");
}
@Test
@DisplayName("the budget is checked before the call starts, naming the bound that was exceeded")
void budgetIsCheckedBeforeTheCallStarts() {
GrpcMetadataBudget tight = new GrpcMetadataBudget(200, 40, 4);
Map<GrpcMetadataKey, String> oversized = new LinkedHashMap<>();
oversized.put(CORRELATION, "x".repeat(64));
assertThatThrownBy(() -> tight.check(oversized))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("user-defined metadata");
Map<GrpcMetadataKey, String> tooManyEntries = new LinkedHashMap<>();
tooManyEntries.put(CORRELATION, "a");
tooManyEntries.put(IDEMPOTENCY, "b");
tooManyEntries.put(GrpcMetadataKey.ascii("x-a"), "c");
tooManyEntries.put(GrpcMetadataKey.ascii("x-b"), "d");
tooManyEntries.put(GrpcMetadataKey.ascii("x-c"), "e");
assertThatThrownBy(() -> tight.check(tooManyEntries))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("entries");
}
@Test
@DisplayName("a request context keeps only allowlisted metadata and refuses the rest loudly")
void requestContextRefusesMetadataOutsideTheAllowlist() {
Map<GrpcMetadataKey, String> inbound =
Map.of(CORRELATION, "corr-1", GrpcMetadataKey.ascii("x-tenant-claim"), "other-tenant");
assertThatThrownBy(
() ->
GrpcRequestContext.create(
GET,
RpcType.UNARY,
GrpcClientIdentity.anonymous(),
budget(),
new GrpcCancellationToken(),
inbound,
Set.of(CORRELATION),
GrpcMetadataBudget.standard(),
null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("x-tenant-claim")
.hasMessageContaining("allowlist");
}
@Test
@DisplayName("a request context exposes no raw metadata map and is immutable once built")
void requestContextIsImmutable() {
Map<GrpcMetadataKey, String> inbound = new LinkedHashMap<>();
inbound.put(CORRELATION, "corr-1");
GrpcRequestContext context =
GrpcRequestContext.create(
GET,
RpcType.UNARY,
GrpcClientIdentity.fromVerifiedAuthentication("actor-1", "tenant-1", "jwt-issuer"),
budget(),
new GrpcCancellationToken(),
inbound,
Set.of(CORRELATION),
GrpcMetadataBudget.standard(),
"trace-1");
inbound.put(IDEMPOTENCY, "leaked-after-the-fact");
assertThat(context.metadata()).containsOnlyKeys(CORRELATION);
assertThat(context.metadataValue(CORRELATION)).contains("corr-1");
assertThat(context.metadataValue(IDEMPOTENCY)).isEmpty();
assertThat(context.traceId()).contains("trace-1");
assertThat(context.live()).isTrue();
}
@Test
@DisplayName("identity names the verifier that established it")
void identityNamesItsVerifier() {
GrpcClientIdentity identity =
GrpcClientIdentity.fromVerifiedAuthentication("actor-1", "tenant-1", "mtls-peer-subject");
assertThat(identity.authenticationSource()).isEqualTo("mtls-peer-subject");
assertThatThrownBy(
() -> GrpcClientIdentity.fromVerifiedAuthentication("actor-1", "tenant-1", " "))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("authentication source");
}
@Test
@DisplayName("a cancelled or expired context is not live")
void cancelledContextIsNotLive() {
GrpcCancellationToken token = new GrpcCancellationToken();
GrpcRequestContext context =
GrpcRequestContext.create(
GET,
RpcType.UNARY,
GrpcClientIdentity.anonymous(),
budget(),
token,
Map.of(),
Set.of(),
GrpcMetadataBudget.standard(),
null);
token.cancel("client-cancelled", java.time.Instant.parse("2026-08-30T10:00:00Z"));
assertThat(context.live()).isFalse();
}
}
@@ -0,0 +1,83 @@
package dev.caskeleton.grpc.core;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class GrpcCoreIdentifiersTest {
@Test
@DisplayName("a full method name round-trips through its canonical form")
void methodNameRoundTripsThroughCanonicalForm() {
GrpcMethodName parsed =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
assertThat(parsed.canonical()).isEqualTo("hyeonworks.document.v1.DocumentService/GetDocument");
assertThat(parsed.method()).isEqualTo("GetDocument");
assertThat(parsed.service().simpleName()).isEqualTo("DocumentService");
assertThat(parsed.service().protoPackage()).isEqualTo("hyeonworks.document.v1");
assertThat(GrpcMethodName.parse(parsed.canonical())).isEqualTo(parsed);
}
@ParameterizedTest
@ValueSource(
strings = {
"DocumentService/Get",
"hyeonworks.document.v1.DocumentService",
"hyeonworks.document.v1.DocumentService/Get/Extra",
"hyeonworks.document.v1.DocumentService/getDocument"
})
@DisplayName("a name that is not exactly package.Service/Method is refused")
void nonCanonicalMethodNamesAreRefused(String candidate) {
assertThatThrownBy(() -> GrpcMethodName.parse(candidate))
.isInstanceOf(IllegalArgumentException.class);
}
@ParameterizedTest
@ValueSource(strings = {"", "svc name", "Has Space", "tab\tseparated"})
@DisplayName("blank, whitespace and control-character identifiers are refused")
void blankAndControlCharacterIdentifiersAreRefused(String candidate) {
assertThatThrownBy(() -> new GrpcServiceName(candidate))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new GrpcChannelProfileName(candidate))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("a channel profile name is lower-kebab-case")
void channelProfileNameIsLowerKebabCase() {
assertThat(new GrpcChannelProfileName("document-service-read").value())
.isEqualTo("document-service-read");
assertThatThrownBy(() -> new GrpcChannelProfileName("DocumentService"))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("RpcType has exactly the four shapes, and only the two Stable ones are Stable")
void rpcTypeHasExactlyFourShapesAndTwoAreStable() {
assertThat(RpcType.values())
.containsExactly(
RpcType.UNARY,
RpcType.SERVER_STREAMING,
RpcType.CLIENT_STREAMING,
RpcType.BIDI_STREAMING);
assertThat(RpcType.UNARY.stable()).isTrue();
assertThat(RpcType.SERVER_STREAMING.stable()).isTrue();
assertThat(RpcType.CLIENT_STREAMING.stable()).isFalse();
assertThat(RpcType.BIDI_STREAMING.stable()).isFalse();
assertThat(RpcType.UNARY.streaming()).isFalse();
assertThat(RpcType.SERVER_STREAMING.streaming()).isTrue();
}
@Test
@DisplayName("no status code claims, on its own, that the server never started")
void statusCodeNeverProvesTheServerDidNotStart() {
assertThat(GrpcStatusCode.values()).noneMatch(GrpcStatusCode::provesNotStarted);
assertThat(GrpcStatusCode.DEADLINE_EXCEEDED.value()).isEqualTo(4);
assertThat(GrpcStatusCode.UNAVAILABLE.value()).isEqualTo(14);
}
}
@@ -0,0 +1,60 @@
package dev.caskeleton.grpc.core;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcStableModuleCatalogTest {
@Test
@DisplayName("the Stable module set is exact and excludes every advanced module")
void stableModuleSetIsExactAndAdvancedIsExcluded() {
GrpcStableModuleCatalog catalog = GrpcStableModuleCatalog.standard();
assertThat(catalog.modules())
.contains("grpc-core-api", "grpc-client", "grpc-server", "grpc-policy")
.doesNotContainAnyElementsOf(GrpcStableModuleCatalog.advancedModules());
assertThat(GrpcStableBuildInvariant.advancedDependencyAllowed()).isFalse();
}
@Test
@DisplayName("the Stable and Advanced sets are disjoint")
void stableAndAdvancedAreDisjoint() {
GrpcStableModuleCatalog catalog = GrpcStableModuleCatalog.standard();
assertThat(GrpcStableModuleCatalog.advancedModules()).isNotEmpty().noneMatch(catalog::isStable);
}
@Test
@DisplayName("a Stable module reaching an advanced module fails, naming every leak")
void stableModuleMayNotDependOnAdvanced() {
Set<String> dependencies =
Set.of("grpc-core-api", "grpc-advanced-streaming", "grpc-advanced-compat");
assertThatThrownBy(
() ->
GrpcStableBuildInvariant.requireNoAdvancedDependency(
"grpc-spring-boot-starter", dependencies))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("grpc-spring-boot-starter")
.hasMessageContaining("grpc-advanced-streaming")
.hasMessageContaining("grpc-advanced-compat");
}
@Test
@DisplayName("a Stable module with only Stable dependencies passes")
void stableOnlyDependenciesPass() {
GrpcStableBuildInvariant.requireNoAdvancedDependency(
"grpc-server", Set.of("grpc-core-api", "grpc-policy"));
}
@Test
@DisplayName("an advanced module is not judged by the Stable invariant")
void advancedModuleIsNotGovernedByTheStableRule() {
GrpcStableBuildInvariant.requireNoAdvancedDependency(
"grpc-advanced-compat", Set.of("grpc-advanced-bootstrap"));
}
}
@@ -0,0 +1,124 @@
package dev.caskeleton.grpc.deadline;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Duration;
import java.time.Instant;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcDeadlineBudgetTest {
private static final GrpcDeadlineProfile TWO_SECONDS =
GrpcDeadlineProfile.of(Duration.ofSeconds(2));
@Test
@DisplayName("a Stable unary call without a propagated deadline is refused")
void unaryEntryPointRequiresAPositiveDeadline() {
assertThatThrownBy(() -> GrpcDeadlineBudget.forEntryPoint(null, TWO_SECONDS))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("positive deadline");
assertThatThrownBy(() -> GrpcDeadlineBudget.forEntryPoint(Duration.ZERO, TWO_SECONDS))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("the budget takes the shorter of the parent remaining and the method default")
void budgetTakesTheShorterOfParentAndMethodDefault() {
GrpcDeadlineBudget fromShortParent =
GrpcDeadlineBudget.forEntryPoint(Duration.ofMillis(500), TWO_SECONDS);
GrpcDeadlineBudget fromLongParent =
GrpcDeadlineBudget.forEntryPoint(Duration.ofSeconds(30), TWO_SECONDS);
assertThat(fromShortParent.remaining())
.isEqualTo(Duration.ofMillis(500).minus(TWO_SECONDS.safetyMargin()));
assertThat(fromLongParent.remaining())
.isEqualTo(TWO_SECONDS.total().minus(TWO_SECONDS.safetyMargin()));
}
@Test
@DisplayName("the safety reserve is subtracted so serialization and trailers have room")
void safetyReserveIsSubtracted() {
GrpcDeadlineProfile profile =
new GrpcDeadlineProfile(
Duration.ofSeconds(10), Duration.ofSeconds(1), Duration.ofMillis(100));
GrpcDeadlineBudget budget = GrpcDeadlineBudget.forEntryPoint(Duration.ofSeconds(10), profile);
assertThat(budget.remaining()).isEqualTo(Duration.ofSeconds(9));
assertThat(profile.usable()).isEqualTo(Duration.ofSeconds(9));
}
@Test
@DisplayName("a downstream call never gets more time than its caller has left")
void downstreamNeverExceedsTheCaller() {
GrpcDeadlineBudget caller =
GrpcDeadlineBudget.forEntryPoint(Duration.ofMillis(400), TWO_SECONDS);
GrpcDeadlineProfile generousDownstream =
new GrpcDeadlineProfile(Duration.ofSeconds(30), Duration.ZERO, Duration.ofMillis(10));
GrpcDeadlineBudget downstream = caller.deriveDownstream(generousDownstream);
assertThat(downstream.remaining()).isLessThanOrEqualTo(caller.remaining());
}
@Test
@DisplayName("a dependency call is not started with less than the minimum attempt budget")
void dependencyCallIsRefusedBelowTheMinimumAttemptBudget() {
GrpcDeadlineProfile profile =
new GrpcDeadlineProfile(
Duration.ofSeconds(1), Duration.ofMillis(100), Duration.ofMillis(250));
assertThat(new GrpcDeadlineBudget(Duration.ofMillis(300), profile).canStartDependencyCall())
.isTrue();
assertThat(new GrpcDeadlineBudget(Duration.ofMillis(100), profile).canStartDependencyCall())
.isFalse();
assertThat(new GrpcDeadlineBudget(Duration.ZERO, profile).canStartDependencyCall()).isFalse();
}
@Test
@DisplayName("a profile whose reserve leaves no usable time is refused")
void profileWithNoUsableTimeIsRefused() {
assertThatThrownBy(
() ->
new GrpcDeadlineProfile(
Duration.ofSeconds(1), Duration.ofSeconds(1), Duration.ZERO))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("leaves no time");
assertThatThrownBy(() -> GrpcDeadlineProfile.of(Duration.ZERO))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("cancellation is idempotent and keeps the first reason")
void cancellationIsIdempotentAndKeepsTheFirstReason() {
GrpcCancellationToken token = new GrpcCancellationToken();
Instant first = Instant.parse("2026-08-30T10:00:00Z");
assertThat(token.cancel("client-cancelled", first)).isTrue();
assertThat(token.cancel("server-drain", first.plusSeconds(1))).isFalse();
assertThat(token.cancelled()).isTrue();
assertThat(token.cancellation())
.hasValueSatisfying(
c -> {
assertThat(c.reason()).isEqualTo("client-cancelled");
assertThat(c.at()).isEqualTo(first);
});
}
@Test
@DisplayName("a cancelled call refuses to start a new side effect")
void cancelledCallRefusesANewSideEffect() {
GrpcCancellationToken token = new GrpcCancellationToken();
token.requireNotCancelled("charge-card");
token.cancel("deadline-exceeded", Instant.parse("2026-08-30T10:00:00Z"));
assertThatThrownBy(() -> token.requireNotCancelled("charge-card"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("charge-card")
.hasMessageContaining("deadline-exceeded");
}
}
@@ -0,0 +1,182 @@
package dev.caskeleton.grpc.error;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.core.GrpcStatusCode;
import dev.caskeleton.grpc.core.RpcType;
import dev.caskeleton.grpc.evidence.GrpcBusinessEvidence;
import dev.caskeleton.grpc.evidence.GrpcExecutionEvidence;
import dev.caskeleton.grpc.evidence.GrpcStreamEvidence;
import dev.caskeleton.grpc.evidence.GrpcTransportEvidence;
import java.time.Duration;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcFailureContextTest {
private static final GrpcMethodName CREATE =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument");
private static final GrpcMethodName WATCH =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/WatchDocuments");
private static GrpcExecutionEvidence sentButUnanswered() {
return new GrpcExecutionEvidence(
CREATE,
RpcType.UNARY,
GrpcTransportEvidence.SENT_UNCONFIRMED,
GrpcBusinessEvidence.ATTEMPTED,
GrpcStreamEvidence.none());
}
@Test
@DisplayName("a deadline on a mutation is COMPLETION_UNKNOWN, not a rejection")
void deadlineOnAMutationIsCompletionUnknown() {
GrpcCompletionOutcome outcome =
GrpcCompletionOutcome.forMutation(GrpcStatusCode.DEADLINE_EXCEEDED, sentButUnanswered());
assertThat(outcome).isEqualTo(GrpcCompletionOutcome.COMPLETION_UNKNOWN);
assertThat(outcome.requiresReconciliation()).isTrue();
}
@Test
@DisplayName("UNAVAILABLE alone does not make a mutation safe to re-issue")
void unavailableAloneDoesNotMakeAMutationRepeatable() {
assertThat(GrpcCompletionOutcome.forMutation(GrpcStatusCode.UNAVAILABLE, sentButUnanswered()))
.isEqualTo(GrpcCompletionOutcome.COMPLETION_UNKNOWN);
}
@Test
@DisplayName("a send the client watched fail is a rejection, because nothing ran")
void observedFailedSendIsARejection() {
GrpcExecutionEvidence notSent = GrpcExecutionEvidence.notStarted(CREATE, RpcType.UNARY);
assertThat(GrpcCompletionOutcome.forMutation(GrpcStatusCode.UNAVAILABLE, notSent))
.isEqualTo(GrpcCompletionOutcome.REJECTED);
}
@Test
@DisplayName("a confirmed commit outranks whatever status the transport reported")
void confirmedCommitOutranksTheStatus() {
GrpcExecutionEvidence committed = sentButUnanswered().withCommitConfirmed();
assertThat(GrpcCompletionOutcome.forMutation(GrpcStatusCode.DEADLINE_EXCEEDED, committed))
.isEqualTo(GrpcCompletionOutcome.COMPLETED);
}
@Test
@DisplayName("a delivered stream prefix is PARTIAL_STREAM")
void deliveredStreamPrefixIsPartialStream() {
GrpcExecutionEvidence partial =
new GrpcExecutionEvidence(
WATCH,
RpcType.SERVER_STREAMING,
GrpcTransportEvidence.RESPONSE_MESSAGE_SEEN,
GrpcBusinessEvidence.NONE,
new GrpcStreamEvidence.Partial(9L));
assertThat(GrpcCompletionOutcome.forMutation(GrpcStatusCode.UNAVAILABLE, partial))
.isEqualTo(GrpcCompletionOutcome.PARTIAL_STREAM);
}
@Test
@DisplayName("the redacted summary carries identity and timing, never payload or credentials")
void redactedSummaryCarriesNoSecrets() {
GrpcFailureContext context =
new GrpcFailureContext(
CREATE,
GrpcStatusCode.DEADLINE_EXCEEDED,
GrpcFailureCategory.DEADLINE,
sentButUnanswered(),
GrpcCompletionOutcome.COMPLETION_UNKNOWN,
GrpcFailureContext.RetryDisposition.RESOLVE_FIRST,
2,
Duration.ofMillis(1500),
Optional.of("trace-abc"));
String summary = context.redactedSummary();
assertThat(summary)
.contains("hyeonworks.document.v1.DocumentService/CreateDocument")
.contains("DEADLINE_EXCEEDED")
.contains("COMPLETION_UNKNOWN")
.contains("RESOLVE_FIRST")
.contains("elapsedMs=1500")
.contains("trace-abc");
}
@Test
@DisplayName("a failure context whose method disagrees with its evidence is refused")
void methodMustAgreeWithEvidence() {
assertThatThrownBy(
() ->
new GrpcFailureContext(
WATCH,
GrpcStatusCode.INTERNAL,
GrpcFailureCategory.INTERNAL,
sentButUnanswered(),
GrpcCompletionOutcome.COMPLETION_UNKNOWN,
GrpcFailureContext.RetryDisposition.NOT_RETRYABLE,
1,
Duration.ZERO,
Optional.empty()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("disagrees with its evidence");
}
@Test
@DisplayName(
"the platform exception says only what the redacted summary says, and keeps the cause")
void platformExceptionMessageIsTheRedactedSummary() {
GrpcFailureContext context =
new GrpcFailureContext(
CREATE,
GrpcStatusCode.INTERNAL,
GrpcFailureCategory.PERMANENT_DEPENDENCY,
sentButUnanswered(),
GrpcCompletionOutcome.COMPLETION_UNKNOWN,
GrpcFailureContext.RetryDisposition.RESOLVE_FIRST,
1,
Duration.ofMillis(10),
Optional.empty());
Exception driverFailure = new IllegalStateException("ERROR: duplicate key value violates ...");
GrpcPlatformException exception = new GrpcPlatformException(context, driverFailure);
assertThat(exception).hasMessage(context.redactedSummary());
assertThat(exception.getMessage()).doesNotContain("duplicate key");
assertThat(exception).hasCause(driverFailure);
assertThat(exception.requiresReconciliation()).isTrue();
}
@Test
@DisplayName("every failure category maps to a canonical status code")
void everyCategoryMapsToAStatusCode() {
assertThat(GrpcFailureCategory.values())
.allSatisfy(c -> assertThat(c.statusCode()).isNotNull());
assertThat(GrpcFailureCategory.VALIDATION.statusCode())
.isEqualTo(GrpcStatusCode.INVALID_ARGUMENT);
assertThat(GrpcFailureCategory.TRANSIENT_DEPENDENCY.transientFailure()).isTrue();
assertThat(GrpcFailureCategory.PERMANENT_DEPENDENCY.transientFailure()).isFalse();
}
@Test
@DisplayName("an attempt counter below one is refused")
void attemptIsOneBased() {
assertThatThrownBy(
() ->
new GrpcFailureContext(
CREATE,
GrpcStatusCode.INTERNAL,
GrpcFailureCategory.INTERNAL,
sentButUnanswered(),
GrpcCompletionOutcome.COMPLETION_UNKNOWN,
GrpcFailureContext.RetryDisposition.TERMINAL,
0,
Duration.ZERO,
Optional.empty()))
.isInstanceOf(IllegalArgumentException.class);
}
}
@@ -0,0 +1,120 @@
package dev.caskeleton.grpc.evidence;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.core.RpcType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcExecutionEvidenceTest {
private static final GrpcMethodName CREATE =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument");
private static final GrpcMethodName WATCH =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/WatchDocuments");
@Test
@DisplayName("seeing response headers never promotes the business axis to a confirmed commit")
void responseHeadersAreNotPromotedToCommitConfirmed() {
GrpcExecutionEvidence attempted =
new GrpcExecutionEvidence(
CREATE,
RpcType.UNARY,
GrpcTransportEvidence.SENT_UNCONFIRMED,
GrpcBusinessEvidence.ATTEMPTED,
GrpcStreamEvidence.none());
GrpcExecutionEvidence afterHeaders = attempted.withResponseHeadersSeen();
assertThat(afterHeaders.transport()).isEqualTo(GrpcTransportEvidence.RESPONSE_HEADERS_SEEN);
assertThat(afterHeaders.business()).isEqualTo(GrpcBusinessEvidence.ATTEMPTED);
assertThat(afterHeaders.transport().serverResponded()).isTrue();
}
@Test
@DisplayName("a unary call may not carry stream evidence")
void unaryCallMayNotCarryStreamEvidence() {
assertThatThrownBy(
() ->
new GrpcExecutionEvidence(
CREATE,
RpcType.UNARY,
GrpcTransportEvidence.RESPONSE_MESSAGE_SEEN,
GrpcBusinessEvidence.ATTEMPTED,
new GrpcStreamEvidence.Partial(7L)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("unary");
}
@Test
@DisplayName("a request that was never sent cannot carry business evidence")
void unsentRequestCannotCarryBusinessEvidence() {
assertThatThrownBy(
() ->
new GrpcExecutionEvidence(
CREATE,
RpcType.UNARY,
GrpcTransportEvidence.NOT_SENT,
GrpcBusinessEvidence.COMMIT_CONFIRMED,
GrpcStreamEvidence.none()))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("APPLIED requires a named application acknowledgement, not transport progress")
void appliedRequiresAnApplicationAcknowledgement() {
assertThatThrownBy(() -> new GrpcStreamEvidence.Applied(4L, " "))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("acknowledgement");
GrpcStreamEvidence applied = new GrpcStreamEvidence.Applied(4L, "consumer-checkpoint-store");
assertThat(applied.delivered()).isTrue();
}
@Test
@DisplayName("stream evidence is a closed set of four shapes")
void streamEvidenceIsAClosedSetOfFourShapes() {
assertThat(GrpcStreamEvidence.class.getPermittedSubclasses())
.extracting(Class::getSimpleName)
.containsExactlyInAnyOrder("None", "Partial", "Applied", "Resumable");
assertThat(GrpcStreamEvidence.none().delivered()).isFalse();
}
@Test
@DisplayName("a delivered stream prefix forbids a whole-call retry")
void deliveredStreamPrefixForbidsWholeCallRetry() {
GrpcExecutionEvidence partial =
new GrpcExecutionEvidence(
WATCH,
RpcType.SERVER_STREAMING,
GrpcTransportEvidence.RESPONSE_MESSAGE_SEEN,
GrpcBusinessEvidence.NONE,
new GrpcStreamEvidence.Partial(12L));
assertThat(partial.permitsWholeCallRetry()).isFalse();
assertThat(
GrpcExecutionEvidence.notStarted(WATCH, RpcType.SERVER_STREAMING)
.permitsWholeCallRetry())
.isTrue();
}
@Test
@DisplayName("an unknown commit is never safe to repeat without a guard")
void unknownCommitIsNeverSafeToRepeat() {
GrpcExecutionEvidence unknown =
GrpcExecutionEvidence.notStarted(CREATE, RpcType.UNARY)
.withResponseHeadersSeen()
.withCommitUnknown();
assertThat(unknown.business().safeToRepeatWithoutGuard()).isFalse();
assertThat(unknown.permitsWholeCallRetry()).isFalse();
}
@Test
@DisplayName("evidence and its owning failure must name the same method")
void evidenceCarriesItsOwnMethod() {
assertThat(GrpcExecutionEvidence.notStarted(CREATE, RpcType.UNARY).method()).isEqualTo(CREATE);
}
}
@@ -0,0 +1,183 @@
package dev.caskeleton.grpc.policy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.core.RpcType;
import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile;
import java.time.Duration;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcMethodPolicyCatalogTest {
private static final GrpcMethodName GET =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
private static final GrpcMethodName CREATE =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument");
private static final GrpcMethodName WATCH =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/WatchDocuments");
private static final GrpcDeadlineProfile TWO_SECONDS =
GrpcDeadlineProfile.of(Duration.ofSeconds(2));
@Test
@DisplayName("the idempotency profile has exactly the six documented values")
void idempotencyProfileHasSixValues() {
assertThat(RpcIdempotencyProfile.values())
.containsExactly(
RpcIdempotencyProfile.READ_ONLY,
RpcIdempotencyProfile.NATURALLY_IDEMPOTENT,
RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED,
RpcIdempotencyProfile.CONDITIONALLY_IDEMPOTENT,
RpcIdempotencyProfile.NON_IDEMPOTENT,
RpcIdempotencyProfile.STREAMING);
}
@Test
@DisplayName("only a read-only unary method may be hedged")
void onlyReadOnlyUnaryMayBeHedged() {
assertThat(GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS).hedgingAllowed()).isTrue();
assertThat(GrpcMethodPolicy.nonIdempotentUnary(CREATE, TWO_SECONDS).hedgingAllowed()).isFalse();
assertThat(RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED.hedgingAllowed()).isFalse();
}
@Test
@DisplayName("attaching an explicit retry policy to a NON_IDEMPOTENT method fails to build")
void nonIdempotentMethodRejectsExplicitRetry() {
assertThatThrownBy(
() ->
new GrpcMethodPolicy(
CREATE,
RpcType.UNARY,
RpcIdempotencyProfile.NON_IDEMPOTENT,
TWO_SECONDS,
WaitForReadyPolicy.DISABLED,
true,
1024,
1024))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("NON_IDEMPOTENT")
.hasMessageContaining("explicit retry");
}
@Test
@DisplayName("a streaming method rejects a whole-call retry policy")
void streamingMethodRejectsWholeCallRetry() {
assertThatThrownBy(
() ->
new GrpcMethodPolicy(
WATCH,
RpcType.SERVER_STREAMING,
RpcIdempotencyProfile.STREAMING,
TWO_SECONDS,
WaitForReadyPolicy.DISABLED,
true,
1024,
1024))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("prefix");
}
@Test
@DisplayName("a unary method may not claim the STREAMING idempotency profile")
void unaryMethodMayNotClaimTheStreamingProfile() {
assertThatThrownBy(
() ->
new GrpcMethodPolicy(
GET,
RpcType.UNARY,
RpcIdempotencyProfile.STREAMING,
TWO_SECONDS,
WaitForReadyPolicy.DISABLED,
false,
1024,
1024))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("STREAMING profile");
}
@Test
@DisplayName("every Stable method carries a deadline profile")
void everyStableMethodCarriesADeadline() {
assertThatThrownBy(
() ->
new GrpcMethodPolicy(
GET,
RpcType.UNARY,
RpcIdempotencyProfile.READ_ONLY,
null,
WaitForReadyPolicy.DISABLED,
false,
1024,
1024))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("deadline profile");
}
@Test
@DisplayName("wait-for-ready is off by default")
void waitForReadyIsOffByDefault() {
assertThat(GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS).waitForReady())
.isEqualTo(WaitForReadyPolicy.DISABLED);
assertThat(WaitForReadyPolicy.DISABLED.queues()).isFalse();
assertThat(WaitForReadyPolicy.WORKER_OPT_IN.queues()).isTrue();
}
@Test
@DisplayName("registering the same method twice fails rather than picking a winner")
void duplicateRegistrationFails() {
GrpcMethodPolicyCatalog.Builder builder =
GrpcMethodPolicyCatalog.builder()
.register(GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS));
assertThatThrownBy(() -> builder.register(GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("duplicate method policy");
}
@Test
@DisplayName("a policy for a method the schema does not declare fails the catalog build")
void policyForAnUndeclaredMethodFailsTheBuild() {
GrpcMethodPolicyCatalog.Builder builder =
GrpcMethodPolicyCatalog.builder()
.withDescriptorMethods(Set.of(GET))
.register(GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS))
.register(GrpcMethodPolicy.nonIdempotentUnary(CREATE, TWO_SECONDS));
assertThatThrownBy(builder::build)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("CreateDocument");
}
@Test
@DisplayName("a method with no policy is refused rather than given defaults")
void missingPolicyIsRefusedRatherThanDefaulted() {
GrpcMethodPolicyCatalog catalog =
GrpcMethodPolicyCatalog.builder()
.register(GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS))
.build();
assertThat(catalog.find(GET)).isPresent();
assertThat(catalog.find(CREATE)).isEmpty();
assertThat(catalog.size()).isEqualTo(1);
assertThatThrownBy(() -> catalog.require(CREATE))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("no deadline and no idempotency profile");
}
@Test
@DisplayName("the descriptor and the catalog agree on one canonical spelling of a method")
void descriptorAndCatalogShareOneCanonicalName() {
GrpcMethodPolicyCatalog catalog =
GrpcMethodPolicyCatalog.builder()
.withDescriptorMethods(Set.of(GET, CREATE))
.register(GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS))
.build();
assertThat(catalog.methods())
.extracting(GrpcMethodName::canonical)
.containsExactly("hyeonworks.document.v1.DocumentService/GetDocument");
}
}
+9
View File
@@ -0,0 +1,9 @@
apply plugin: 'java-library'
// Stable discovery: Static/DNS resolvers, pick_first/round_robin load balancing, and the
// Kubernetes VIP / headless / mesh routing profiles. Custom resolvers, custom load balancers and
// xDS are Advanced and are refused here by GrpcDiscoveryPolicyValidator.
dependencies {
api project(':grpc:grpc-core-api')
api project(':grpc:grpc-client')
}
+91
View File
@@ -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,67 @@
package dev.caskeleton.grpc.discovery;
import java.util.ArrayList;
import java.util.List;
/**
* Checks a discovery configuration for the things that look right and are not.
*
* <p>Also refuses Advanced schemes by name. {@code xds:///} in a Stable profile is not a
* configuration mistake to warn about — it is a capability with its own control plane, its own
* failure modes and its own promotion gate, and letting it through here would make "Stable supports
* DNS and static" untrue.
*/
public final class GrpcDiscoveryPolicyValidator {
/** Schemes that exist but belong to Advanced capabilities. */
private static final List<String> ADVANCED_SCHEMES = List.of("xds", "consul", "etcd", "eureka");
private GrpcDiscoveryPolicyValidator() {}
/**
* Every problem with {@code profile}.
*
* @return an empty list when the configuration does what it claims
*/
public static List<String> violations(GrpcResolverProfile profile) {
if (profile == null) {
throw new IllegalArgumentException("a resolver profile is required");
}
List<String> violations = new ArrayList<>();
if (!profile.loadBalancingEffective()) {
violations.add(
profile.loadBalancingPolicy()
+ " over "
+ profile.expectedAddressCount()
+ " address distributes nothing; use "
+ GrpcStableLoadBalancer.recommendedFor(profile.expectedAddressCount())
+ " or a resolver that returns several endpoints");
}
return List.copyOf(violations);
}
/**
* Refuses a target whose scheme is not Stable.
*
* @throws IllegalArgumentException naming the Advanced capability the scheme belongs to
*/
public static GrpcResolverType requireStableScheme(String scheme) {
if (scheme == null || scheme.isBlank()) {
throw new IllegalArgumentException("a target carries a resolver scheme");
}
if (ADVANCED_SCHEMES.contains(scheme)) {
throw new IllegalArgumentException(
"resolver scheme '"
+ scheme
+ "' is an Advanced capability with its own control plane and promotion gate; it is "
+ "not part of the Stable DNS and static support");
}
return GrpcResolverType.forScheme(scheme)
.orElseThrow(
() ->
new IllegalArgumentException(
"unknown resolver scheme '"
+ scheme
+ "'; Stable schemes are dns, static and unix"));
}
}
@@ -0,0 +1,90 @@
package dev.caskeleton.grpc.discovery;
import dev.caskeleton.grpc.resilience.GrpcRetryOwner;
import java.time.Duration;
/**
* A Kubernetes deployment's routing decision, with the stream obligations that come with it.
*
* <p>{@code streamReconnectBudget} is required whenever the profile carries long streams, because a
* stream pins a client to one pod for its whole life. Every rollout, every eviction and every
* scale-down ends that stream, and a deployment that has not decided how a client reconnects has
* decided that it will not.
*/
public record GrpcKubernetesProfile(
GrpcKubernetesRoutingMode routingMode,
GrpcRetryOwner retryOwner,
boolean carriesLongLivedStreams,
Duration streamReconnectBudget,
Duration readinessDrainGrace) {
/** Refuses a profile whose retry owner or drain contract contradicts its routing. */
public GrpcKubernetesProfile {
if (routingMode == null || retryOwner == null) {
throw new IllegalArgumentException("a Kubernetes profile names its routing and retry owner");
}
if (routingMode.meshOwnsRouting() && retryOwner.explicitRetryInProcess()) {
throw new IllegalArgumentException(
"routing mode "
+ routingMode
+ " puts retries in the sidecar; an in-process retry owner of "
+ retryOwner
+ " would multiply every failed call");
}
if (streamReconnectBudget == null || readinessDrainGrace == null) {
throw new IllegalArgumentException("both durations must be present");
}
if (streamReconnectBudget.isNegative() || readinessDrainGrace.isNegative()) {
throw new IllegalArgumentException("durations must not be negative");
}
if (carriesLongLivedStreams && streamReconnectBudget.isZero()) {
throw new IllegalArgumentException(
"a profile with long-lived streams needs a reconnect budget; a stream is pinned to one "
+ "pod and every rollout ends it");
}
if (carriesLongLivedStreams && readinessDrainGrace.isZero()) {
throw new IllegalArgumentException(
"a profile with long-lived streams needs a readiness drain grace; without one a pod stops "
+ "serving before its streams have been told to reconnect");
}
}
/** A VIP profile for short unary traffic. */
public static GrpcKubernetesProfile virtualIp() {
return new GrpcKubernetesProfile(
GrpcKubernetesRoutingMode.K8S_VIP,
GrpcRetryOwner.GRPC_PLATFORM,
false,
Duration.ZERO,
Duration.ofSeconds(10));
}
/** A headless profile carrying long-lived subscriptions. */
public static GrpcKubernetesProfile headlessStreaming() {
return new GrpcKubernetesProfile(
GrpcKubernetesRoutingMode.K8S_HEADLESS,
GrpcRetryOwner.GRPC_PLATFORM,
true,
Duration.ofSeconds(5),
Duration.ofSeconds(30));
}
/** A mesh-routed profile, where the sidecar owns retries. */
public static GrpcKubernetesProfile mesh() {
return new GrpcKubernetesProfile(
GrpcKubernetesRoutingMode.MESH,
GrpcRetryOwner.SERVICE_MESH,
false,
Duration.ZERO,
Duration.ofSeconds(10));
}
/** The resolver profile this routing mode implies. */
public GrpcResolverProfile resolverProfile(int expectedAddressCount) {
return new GrpcResolverProfile(
GrpcResolverType.DNS,
routingMode.loadBalancingPolicy(),
Duration.ofSeconds(30),
expectedAddressCount);
}
}

Some files were not shown because too many files have changed in this diff Show More