refactor: 각 어댑터터별 리펙토링 진행
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// Redis SDK leaf — see docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md.
|
||||
//
|
||||
// The design models the SDK as separate Gradle modules. This repository's fail-closed 19-leaf
|
||||
// The design models the SDK as separate Gradle modules. This repository's fail-closed module
|
||||
// registry outranks that layout, so the module boundaries are packages under
|
||||
// dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them.
|
||||
dependencies {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// docs/httpclient/repository-adaptation.md (how the design's 19 library modules map here).
|
||||
//
|
||||
// The design models the platform as 19 separate Gradle modules. This repository's fail-closed
|
||||
// 19-leaf registry (src/config/architecture/modules.json) outranks that layout, so the module
|
||||
// module registry (src/config/architecture/modules.json) outranks that layout, so the module
|
||||
// boundaries are packages under dev.caskeleton.adapter.outbound.httpclient and
|
||||
// HttpClientModuleBoundaryTest enforces the design's module dependency table.
|
||||
description = 'Outbound adapter: HTTP client platform (typed clients, profiles, evidence-based retry)'
|
||||
@@ -73,33 +73,12 @@ dependencies {
|
||||
// Performance certification and JMH benchmarks are separate source sets for their own reason: they
|
||||
// are slow, they assert on resource bounds rather than behaviour, and they must never be part of
|
||||
// the default unit lane.
|
||||
sourceSets {
|
||||
testkit {
|
||||
java.srcDir 'src/testkit/java'
|
||||
compileClasspath += sourceSets.main.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
httpClientPerformanceTest {
|
||||
java.srcDir 'src/httpClientPerformanceTest/java'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.testkit.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
jmh {
|
||||
java.srcDir 'src/jmh/java'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.testkit.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
// The testkit compiles against exactly what a test does: testImplementation already extends
|
||||
// implementation, so this is the module's own dependencies plus the test libraries.
|
||||
testkitImplementation.extendsFrom testImplementation
|
||||
testkitRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
httpClientPerformanceTestImplementation.extendsFrom testImplementation
|
||||
httpClientPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
jmhImplementation.extendsFrom testImplementation
|
||||
jmhRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
strictTestLanes {
|
||||
// The testkit compiles against exactly what a test does: `implementation` inheritance runs
|
||||
// through testImplementation, so this is the module's own dependencies plus the test libraries.
|
||||
sourceSet('testkit') { compilesAgainst 'main' }
|
||||
sourceSet('httpClientPerformanceTest') { compilesAgainst 'main', 'testkit' }
|
||||
sourceSet('jmh') { compilesAgainst 'main', 'testkit' }
|
||||
}
|
||||
|
||||
// Every test lane compiles and runs against the testkit.
|
||||
@@ -166,69 +145,58 @@ tasks.named('test', Test) {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('httpClientBlockHoundTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Proves no platform code blocks a Reactor event loop (design §18.2, §28.6).'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform { includeTags 'httpclient-blockhound' }
|
||||
applyContractSelection(it)
|
||||
// BlockHound instruments already-loaded JDK classes; Java 13+ needs this to redefine them.
|
||||
jvmArgs '-XX:+AllowRedefinitionToAddDeleteMethods'
|
||||
// The lane exists to run BlockHound. Discovering nothing means it did not, which is a failure.
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
// Four tag-selected lanes, declared rather than assembled.
|
||||
//
|
||||
// Two of them — the stable contract suite and the security suite — did not carry
|
||||
// failOnNoDiscoveredTests at all. Five lanes were written by copying the block above, and the
|
||||
// property that makes a lane mean anything was lost on two of the copies, so the cross-transport
|
||||
// contract suite and the SSRF/credential-leak suite would each have reported success on discovering
|
||||
// nothing. Declaring the lanes removes the opportunity: the convention has no opt-out.
|
||||
strictTestLanes {
|
||||
lane('httpClientBlockHoundTest') {
|
||||
tag = 'httpclient-blockhound'
|
||||
description = 'Proves no platform code blocks a Reactor event loop (design §18.2, §28.6).'
|
||||
customize = { test ->
|
||||
applyContractSelection(test)
|
||||
// BlockHound instruments already-loaded JDK classes; Java 13+ needs this to redefine them.
|
||||
test.jvmArgs '-XX:+AllowRedefinitionToAddDeleteMethods'
|
||||
}
|
||||
}
|
||||
lane('httpClientStableContractTest') {
|
||||
tag = 'httpclient-contract'
|
||||
description = 'Runs the cross-transport stable contract suite (design §28.2, §33).'
|
||||
customize = { test -> applyContractSelection(test) }
|
||||
}
|
||||
lane('httpClientSecurityTest') {
|
||||
tag = 'httpclient-security'
|
||||
description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).'
|
||||
customize = { test -> applyContractSelection(test) }
|
||||
}
|
||||
// Its own source set rather than a tag, so the source set is the selection.
|
||||
lane('httpClientPerformanceTest') {
|
||||
sourceSet = 'httpClientPerformanceTest'
|
||||
description = 'Certifies pool, streaming, retry, and rotation resource bounds (design §28.8).'
|
||||
customize = { test ->
|
||||
applyContractSelection(test)
|
||||
test.systemProperty 'performance.assertions.enabled',
|
||||
(project.findProperty('performance.assertions.enabled') ?: 'false').toString()
|
||||
}
|
||||
}
|
||||
lane('httpClientFailureInjectionTest') {
|
||||
tag = 'httpclient-fault'
|
||||
description = 'Runs the Toxiproxy fault-injection suite; fails closed without Docker ' +
|
||||
'(design §28.3).'
|
||||
customize = { test ->
|
||||
applyContractSelection(test)
|
||||
// The upstream image is mutable by default. Passing a digest here is what makes a red
|
||||
// fault run attributable to this repository rather than to someone else's image push.
|
||||
test.systemProperty 'httpclient.fault.httpbin.image',
|
||||
(project.findProperty('httpclient.fault.httpbin.image')
|
||||
?: 'kennethreitz/httpbin:latest').toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('httpClientStableContractTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Runs the cross-transport stable contract suite (design §28.2, §33).'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform { includeTags 'httpclient-contract' }
|
||||
applyContractSelection(it)
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
tasks.register('httpClientSecurityTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform { includeTags 'httpclient-security' }
|
||||
applyContractSelection(it)
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
tasks.register('httpClientFailureInjectionTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Runs the Toxiproxy fault-injection suite; fails closed without Docker (design §28.3).'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform { includeTags 'httpclient-fault' }
|
||||
applyContractSelection(it)
|
||||
// The upstream image is mutable by default. Passing a digest here is what makes a red fault
|
||||
// run attributable to this repository rather than to someone else's image push.
|
||||
systemProperty 'httpclient.fault.httpbin.image',
|
||||
(project.findProperty('httpclient.fault.httpbin.image') ?: 'kennethreitz/httpbin:latest').toString()
|
||||
// A fault suite that never injected a fault must not report success, so a selected lane with no
|
||||
// discovered test is an error rather than an empty pass.
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
tasks.register('httpClientPerformanceTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Certifies pool, streaming, retry, and rotation resource bounds (design §28.8).'
|
||||
testClassesDirs = sourceSets.httpClientPerformanceTest.output.classesDirs
|
||||
classpath = sourceSets.httpClientPerformanceTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
applyContractSelection(it)
|
||||
systemProperty 'performance.assertions.enabled',
|
||||
(project.findProperty('performance.assertions.enabled') ?: 'false').toString()
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
tasks.register('jmh', JavaExec) {
|
||||
group = 'verification'
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation project(':application-core')
|
||||
|
||||
@@ -20,8 +20,13 @@ dependencies {
|
||||
implementation 'io.projectreactor:reactor-core'
|
||||
// JSON Schema 2020-12 validation of template variables, using the same validator and version the
|
||||
// messaging adapter already depends on rather than a second implementation of the same spec.
|
||||
// The YAML dataformat is excluded: schemas are supplied as JSON strings, so pulling a YAML
|
||||
// parser onto the runtime classpath would add attack surface for a format nothing reads.
|
||||
//
|
||||
// Jackson's YAML dataformat is excluded because schemas arrive as JSON strings and a second
|
||||
// parser for a format this leaf never reads is surface for nothing. It does not remove YAML from
|
||||
// the runtime — org.yaml:snakeyaml is on this classpath via spring-boot-starter, which is how
|
||||
// Spring Boot reads application.yml. The comment here used to claim the stronger outcome, and
|
||||
// the resolved graph had said otherwise for as long as it stood; dependencyPolicy below now
|
||||
// states the claim the build can check.
|
||||
// Thymeleaf is the reference HTML renderer, added as the engine only — not the Spring
|
||||
// starter, which would drag a view resolver and a servlet integration onto an outbound
|
||||
// adapter that renders strings and never serves a request.
|
||||
@@ -37,3 +42,13 @@ dependencies {
|
||||
testImplementation 'io.projectreactor:reactor-test'
|
||||
}
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
|
||||
// The exclusion above, stated as something the build verifies rather than something a comment
|
||||
// asserts. verifyDependencyPolicy resolves runtimeClasspath and fails if the coordinate is present.
|
||||
dependencyPolicy {
|
||||
absent 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml',
|
||||
because: 'schemas arrive as JSON strings; a second YAML parser is surface for a format ' +
|
||||
'this leaf never reads'
|
||||
absent 'tools.jackson.dataformat:jackson-dataformat-yaml',
|
||||
because: 'the Jackson 3 coordinate of the same parser, excluded for the same reason'
|
||||
}
|
||||
|
||||
+20
-9
@@ -86,13 +86,19 @@ public record NotificationPlatformSettings(
|
||||
/**
|
||||
* The largest body the platform can retain, derived rather than chosen.
|
||||
*
|
||||
* <p>It was one mebibyte, while the ciphertext column holds 65,536 bytes and encryption adds a
|
||||
* 12-byte nonce and a 16-byte tag. Three layers each enforced a different number: configuration
|
||||
* allowed a mebibyte, the MVC controller hard-coded 65,536, and the database rejected anything
|
||||
* over 65,536 *after* encryption — so a body of exactly the configured maximum passed every
|
||||
* check above the database and failed the CHECK constraint, having already been acknowledged.
|
||||
* <p>It was one mebibyte, while the ciphertext column holds 65,536 bytes and the envelope adds
|
||||
* a version byte, a key id, a nonce and a tag. Three layers each enforced a different number:
|
||||
* configuration allowed a mebibyte, the MVC controller hard-coded 65,536, and the database
|
||||
* rejected anything over 65,536 *after* encryption — so a body of exactly the configured
|
||||
* maximum passed every check above the database and failed the CHECK constraint, having already
|
||||
* been acknowledged.
|
||||
*
|
||||
* <p>Read from the protector rather than restated, because a number written twice is a number
|
||||
* that drifts the first time the envelope gains a field.
|
||||
*/
|
||||
private static final long MAX_BODY_CEILING = 65_536L - 28L;
|
||||
private static final long MAX_BODY_CEILING =
|
||||
dev.caskeleton.adapter.outbound.notification.platform.security
|
||||
.AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES;
|
||||
|
||||
public Callbacks {
|
||||
Objects.requireNonNull(replaySkew, "replaySkew");
|
||||
@@ -100,7 +106,12 @@ public record NotificationPlatformSettings(
|
||||
throw new IllegalArgumentException(
|
||||
"max-body-bytes must be 1.."
|
||||
+ MAX_BODY_CEILING
|
||||
+ "; the ciphertext column holds 65536 bytes and encryption adds 28");
|
||||
+ "; the ciphertext column holds "
|
||||
+ dev.caskeleton.adapter.outbound.notification.platform.security
|
||||
.AesGcmCallbackPayloadProtection.MAX_CIPHERTEXT_BYTES
|
||||
+ " bytes and the envelope adds "
|
||||
+ dev.caskeleton.adapter.outbound.notification.platform.security
|
||||
.AesGcmCallbackPayloadProtection.ENVELOPE_OVERHEAD_BYTES);
|
||||
}
|
||||
if (replaySkew.isNegative()) {
|
||||
throw new IllegalArgumentException("replay-skew must not be negative");
|
||||
@@ -109,8 +120,8 @@ public record NotificationPlatformSettings(
|
||||
|
||||
/** Conservative defaults. */
|
||||
public static Callbacks defaults() {
|
||||
// The storable maximum, not the column size: encryption adds 28 bytes, so a default of
|
||||
// 65,536 was a default that could not be stored.
|
||||
// The storable maximum, not the column size: the envelope adds a version byte, a key id, a
|
||||
// nonce and a tag, so a default of 65,536 was a default that could not be stored.
|
||||
return new Callbacks(false, MAX_BODY_CEILING, Duration.ofMinutes(5));
|
||||
}
|
||||
}
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Which key purposes a given configuration actually needs.
|
||||
*
|
||||
* <p>Startup demanded all eight, always. That is fail-closed in the wrong direction: it made every
|
||||
* deployment provision and rotate keys for capabilities it had switched off — a Web Push signing
|
||||
* key for a platform with no Web Push profile, a callback signing key for a platform with no
|
||||
* callback endpoint — and a key that exists but is never used is a key nobody notices leaking. It
|
||||
* also made the eight look equally load-bearing, so nothing distinguished the four that every mode
|
||||
* needs from the four that follow a capability.
|
||||
*
|
||||
* <p>The direction that must not weaken is the other one: a capability that is switched <em>on</em>
|
||||
* and whose key is missing still refuses the boot, because the alternative is discovering it on a
|
||||
* user's notification. This class decides only what is required; validation of whatever is supplied
|
||||
* happens regardless, so an unused key that is configured is still checked rather than trusted.
|
||||
*/
|
||||
public final class NotificationSecretRequirements {
|
||||
|
||||
private NotificationSecretRequirements() {}
|
||||
|
||||
/**
|
||||
* The purposes this configuration must supply.
|
||||
*
|
||||
* @param settings the bound platform configuration
|
||||
* @return the required purposes, never empty
|
||||
*/
|
||||
public static Set<SecretPurpose> requiredBy(NotificationPlatformSettings settings) {
|
||||
Objects.requireNonNull(settings, "settings");
|
||||
Set<SecretPurpose> required = EnumSet.copyOf(ALWAYS);
|
||||
|
||||
if (settings.callbacks().enabled()) {
|
||||
// The callback endpoint verifies a provider signature and fingerprints the payload for
|
||||
// deduplication. Both happen on the first callback that arrives, so neither key can be
|
||||
// deferred to "when it is needed".
|
||||
required.add(SecretPurpose.CALLBACK_SIGNING);
|
||||
required.add(SecretPurpose.CALLBACK_FINGERPRINT_HMAC);
|
||||
}
|
||||
|
||||
for (Map.Entry<String, NotificationPlatformSettings.Provider> entry :
|
||||
settings.providers().entrySet()) {
|
||||
NotificationPlatformSettings.Provider profile = entry.getValue();
|
||||
if (!profile.enabled()) {
|
||||
continue;
|
||||
}
|
||||
ProviderType type = ProviderType.parse(entry.getKey(), profile.type());
|
||||
if (authenticatesWithAPlatformCredential(type)) {
|
||||
required.add(SecretPurpose.PROVIDER_CREDENTIAL);
|
||||
}
|
||||
if (type == ProviderType.WEB_PUSH) {
|
||||
required.add(SecretPurpose.VAPID_SIGNING);
|
||||
}
|
||||
if (profile.callbackSigningSecretRef() != null
|
||||
&& !profile.callbackSigningSecretRef().isBlank()) {
|
||||
// A profile that names a signing key ref intends to verify or produce signatures whatever
|
||||
// the platform-wide callback switch says.
|
||||
required.add(SecretPurpose.CALLBACK_SIGNING);
|
||||
}
|
||||
}
|
||||
return Set.copyOf(required);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a family authenticates to its provider with a key this platform holds.
|
||||
*
|
||||
* <p>SMTP does not: its relay address, user and password come from Spring's own {@code
|
||||
* spring.mail.*} through the injected mail sender, which is why {@code
|
||||
* SmtpProviderRuntimeAssembler} never touches the secret store. Demanding a provider credential
|
||||
* for an SMTP-only deployment asked an operator to invent a secret with nothing to authenticate
|
||||
* to.
|
||||
*/
|
||||
private static boolean authenticatesWithAPlatformCredential(ProviderType type) {
|
||||
return type != ProviderType.SMTP;
|
||||
}
|
||||
|
||||
/**
|
||||
* The purposes every mode needs, including {@code INGEST_ONLY}.
|
||||
*
|
||||
* <p>Each of these is on the accept path rather than the dispatch path, so switching every
|
||||
* provider off does not switch any of them off. Contact points are encrypted and their lookup
|
||||
* hashes computed when a recipient is resolved; notification variables are encrypted at rest by
|
||||
* the record mapper, which takes the protection as a constructor argument with no fallback; and
|
||||
* provider request ids are hashed by the attempt store and the event ledger on every row they
|
||||
* write.
|
||||
*/
|
||||
private static final Set<SecretPurpose> ALWAYS =
|
||||
Set.of(
|
||||
SecretPurpose.CONTACT_ENCRYPTION,
|
||||
SecretPurpose.CONTACT_LOOKUP_HMAC,
|
||||
SecretPurpose.PAYLOAD_ENCRYPTION,
|
||||
SecretPurpose.PROVIDER_REQUEST_LOOKUP_HMAC);
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.provider;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.TenantId;
|
||||
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
|
||||
import dev.caskeleton.application.notification.platform.provider.AttachmentAccessContext;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
|
||||
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Opens the attachments an email submission declares.
|
||||
*
|
||||
* <p>Every email provider needs the same three things in the same order — resolve, verify, close —
|
||||
* and each of them is a silent failure when a second copy gets one wrong: an unresolved attachment
|
||||
* becomes a mail missing the document it is about, an unverified one becomes bytes nobody approved,
|
||||
* and an unclosed one becomes a leaked stream that only shows up under load.
|
||||
*
|
||||
* <p>The integrity check runs before the provider call, not after. A digest or size that does not
|
||||
* match what the caller pinned at submit time means these are not the approved bytes, and finding
|
||||
* that out once the mail has left is finding it out too late.
|
||||
*/
|
||||
public final class EmailAttachments {
|
||||
|
||||
private EmailAttachments() {}
|
||||
|
||||
/**
|
||||
* Resolves and verifies everything the content declares.
|
||||
*
|
||||
* <p>The caller closes the result on every path, including the failing ones. Content that is not
|
||||
* email, or email that declares nothing, resolves to an empty list rather than to a failure —
|
||||
* having no attachment is the normal case.
|
||||
*/
|
||||
public static List<ResolvedAttachment> open(
|
||||
AttachmentIntegrityGuard guard, ProviderSubmission submission) {
|
||||
Objects.requireNonNull(guard, "guard");
|
||||
Objects.requireNonNull(submission, "submission");
|
||||
if (!(submission.content().content() instanceof EmailContent email)
|
||||
|| email.attachments().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
AttachmentAccessContext context =
|
||||
new AttachmentAccessContext(
|
||||
new TenantId(submission.profile().environment()), submission.attemptId());
|
||||
List<ResolvedAttachment> resolved = new ArrayList<>(email.attachments().size());
|
||||
try {
|
||||
for (var reference : email.attachments()) {
|
||||
resolved.add(guard.resolve(reference, context));
|
||||
}
|
||||
return List.copyOf(resolved);
|
||||
} catch (RuntimeException failure) {
|
||||
// Everything already opened is closed before the failure propagates. Half a resolution is
|
||||
// still half a set of open streams.
|
||||
closeAll(resolved);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes everything that was opened, whatever the send did. */
|
||||
public static void closeAll(List<ResolvedAttachment> attachments) {
|
||||
Objects.requireNonNull(attachments, "attachments");
|
||||
for (ResolvedAttachment attachment : attachments) {
|
||||
try {
|
||||
attachment.close();
|
||||
} catch (Exception ignored) {
|
||||
// A stream that will not close is not a reason to change the send's outcome, and a failed
|
||||
// send is exactly when a leaked one would otherwise go unnoticed.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
-13
@@ -1,24 +1,28 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.EmailAttachments;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
|
||||
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
|
||||
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
|
||||
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
|
||||
import dev.caskeleton.application.notification.platform.security.AccessContext;
|
||||
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -42,25 +46,28 @@ public final class SesNotificationProviderAdapter implements NotificationProvide
|
||||
|
||||
private final NotificationHttpGateway gateway;
|
||||
private final SesRequestMapper mapper;
|
||||
private final AttachmentIntegrityGuard attachmentGuard;
|
||||
private final SesFailureClassifier classifier;
|
||||
private final ContactPointProtector protector;
|
||||
private final SecretMaterialProvider secrets;
|
||||
private final ProviderCredentialManager credentials;
|
||||
private final String accessKeyId;
|
||||
private final Clock clock;
|
||||
|
||||
public SesNotificationProviderAdapter(
|
||||
NotificationHttpGateway gateway,
|
||||
SesRequestMapper mapper,
|
||||
AttachmentIntegrityGuard attachmentGuard,
|
||||
SesFailureClassifier classifier,
|
||||
ContactPointProtector protector,
|
||||
SecretMaterialProvider secrets,
|
||||
ProviderCredentialManager credentials,
|
||||
String accessKeyId,
|
||||
Clock clock) {
|
||||
this.gateway = Objects.requireNonNull(gateway, "gateway");
|
||||
this.mapper = Objects.requireNonNull(mapper, "mapper");
|
||||
this.attachmentGuard = Objects.requireNonNull(attachmentGuard, "attachmentGuard");
|
||||
this.classifier = Objects.requireNonNull(classifier, "classifier");
|
||||
this.protector = Objects.requireNonNull(protector, "protector");
|
||||
this.secrets = Objects.requireNonNull(secrets, "secrets");
|
||||
this.credentials = Objects.requireNonNull(credentials, "credentials");
|
||||
this.accessKeyId = Objects.requireNonNull(accessKeyId, "accessKeyId");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
@@ -77,8 +84,21 @@ public final class SesNotificationProviderAdapter implements NotificationProvide
|
||||
|
||||
@Override
|
||||
public ProviderCapabilities capabilities() {
|
||||
// The payload ceiling is the mapper's own constant rather than a second copy of the number:
|
||||
// the runtime plans against what is declared here and the mapper refuses against what it holds,
|
||||
// and two spellings of the same limit is one of them being wrong.
|
||||
return new ProviderCapabilities(
|
||||
false, false, true, false, false, false, false, false, 1, 10_000_000L, Duration.ofDays(1));
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
SesRequestMapper.MAX_MESSAGE_BYTES,
|
||||
Duration.ofDays(1));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -97,13 +117,28 @@ public final class SesNotificationProviderAdapter implements NotificationProvide
|
||||
throw new IllegalArgumentException("SES requires an email contact point");
|
||||
}
|
||||
|
||||
var request =
|
||||
mapper.map(
|
||||
submission,
|
||||
address.normalized(),
|
||||
accessKeyId,
|
||||
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(),
|
||||
clock.instant());
|
||||
// Resolved and verified before the request is shaped, and closed as soon as it is. An
|
||||
// attachment only reaches the wire through the raw MIME message the mapper builds from these
|
||||
// streams, so they have to be open for exactly that long and no longer.
|
||||
List<ResolvedAttachment> opened = EmailAttachments.open(attachmentGuard, submission);
|
||||
NotificationHttpRequest request;
|
||||
try {
|
||||
request =
|
||||
mapper.map(
|
||||
submission,
|
||||
address.normalized(),
|
||||
opened,
|
||||
accessKeyId,
|
||||
// This profile's credential at the generation the submission was planned against, not
|
||||
// the platform's one current provider credential. Sharing a single key across every
|
||||
// profile made one leaked SES account's key a leak of every provider account, and
|
||||
// made a per-profile rotation inexpressible.
|
||||
credentials.materialFor(
|
||||
submission.profile().profileId(), submission.profile().credentialGeneration()),
|
||||
clock.instant());
|
||||
} finally {
|
||||
EmailAttachments.closeAll(opened);
|
||||
}
|
||||
|
||||
try {
|
||||
NotificationHttpResponse response = gateway.exchange(request);
|
||||
|
||||
+5
-1
@@ -20,7 +20,11 @@ public record SesProviderProperties(
|
||||
Objects.requireNonNull(senderIdentity, "senderIdentity");
|
||||
Objects.requireNonNull(configurationSet, "configurationSet");
|
||||
Objects.requireNonNull(timeout, "timeout");
|
||||
NotificationEndpoints.requireSecureOrLoopback(endpoint, "SES endpoint");
|
||||
// See WebhookSubscription for why this is the stronger guard now. An SES endpoint is operator
|
||||
// configured rather than user supplied, so loopback stays available for local and contract
|
||||
// profiles; what is refused is a configured endpoint that resolves into the deployment's own
|
||||
// network.
|
||||
NotificationEndpoints.requireExternallyRoutable(endpoint, "SES endpoint", true);
|
||||
if (region.isBlank() || senderIdentity.isBlank()) {
|
||||
throw new IllegalArgumentException("region and senderIdentity must not be blank");
|
||||
}
|
||||
|
||||
+137
-14
@@ -2,54 +2,98 @@ package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
|
||||
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
|
||||
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationValidationException;
|
||||
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
|
||||
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
|
||||
import jakarta.mail.MessagingException;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Builds the signed SES v2 send request. */
|
||||
/**
|
||||
* Builds the signed SES v2 send request.
|
||||
*
|
||||
* <p>Two content shapes, chosen by what the notification actually carries. {@code Simple} is a
|
||||
* subject and two bodies and nothing else, so an email that declares an attachment is assembled as
|
||||
* a MIME message and sent as {@code Raw} content instead. Content that declared an attachment used
|
||||
* to be sent as {@code Simple} regardless: the document was silently absent from the mail SES sent
|
||||
* and the attempt was still recorded as delivered, which is a recipient told to read something that
|
||||
* is not there.
|
||||
*
|
||||
* <p>The MIME message is built by the factory the SMTP provider already uses, rather than by a
|
||||
* second assembly of the same rendered email. Multipart layout, UTF-8 and the header-separator
|
||||
* rejection that keeps a subject from turning a notification into someone else's mail are decided
|
||||
* once; two builders would be two places for those answers to drift apart.
|
||||
*
|
||||
* <p>What the raw path still cannot express is refused before the request is signed, so nothing has
|
||||
* been sent when it happens: content that is not email, an attachment the resolver did not hand
|
||||
* back, and a message larger than SES will accept.
|
||||
*/
|
||||
public final class SesRequestMapper {
|
||||
|
||||
/**
|
||||
* The largest message SES accepts, measured on the bytes that go on the wire.
|
||||
*
|
||||
* <p>Measured after assembly rather than against the declared attachment sizes, because base64
|
||||
* transfer encoding adds a third: a set of parts that clears the limit before encoding and
|
||||
* exceeds it after would be rejected by SES with the attempt already made, and an attempt made is
|
||||
* an attempt the evidence model has to reason about.
|
||||
*/
|
||||
public static final long MAX_MESSAGE_BYTES = 10_000_000L;
|
||||
|
||||
private static final String PATH = "/v2/email/outbound-emails";
|
||||
|
||||
private final SesProviderProperties properties;
|
||||
private final AwsSignatureV4Signer signer;
|
||||
private final SmtpMimeMessageFactory mimeFactory;
|
||||
|
||||
public SesRequestMapper(SesProviderProperties properties, AwsSignatureV4Signer signer) {
|
||||
public SesRequestMapper(
|
||||
SesProviderProperties properties,
|
||||
AwsSignatureV4Signer signer,
|
||||
SmtpMimeMessageFactory mimeFactory) {
|
||||
this.properties = Objects.requireNonNull(properties, "properties");
|
||||
this.signer = Objects.requireNonNull(signer, "signer");
|
||||
this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory");
|
||||
}
|
||||
|
||||
/** Map one submission into a signed request. */
|
||||
/**
|
||||
* Map one submission into a signed request.
|
||||
*
|
||||
* @param attachments the already resolved and verified attachments, open for the length of this
|
||||
* call; the caller closes them
|
||||
*/
|
||||
public NotificationHttpRequest map(
|
||||
ProviderSubmission submission,
|
||||
String recipientAddress,
|
||||
List<ResolvedAttachment> attachments,
|
||||
String accessKeyId,
|
||||
byte[] secretAccessKey,
|
||||
Instant signedAt) {
|
||||
Objects.requireNonNull(submission, "submission");
|
||||
Objects.requireNonNull(recipientAddress, "recipientAddress");
|
||||
Objects.requireNonNull(attachments, "attachments");
|
||||
if (!(submission.content().content() instanceof EmailContent email)) {
|
||||
throw new IllegalArgumentException("SES requires email content");
|
||||
}
|
||||
|
||||
Map<String, Object> simple = new LinkedHashMap<>();
|
||||
simple.put("Subject", Map.of("Data", email.subject(), "Charset", "UTF-8"));
|
||||
Map<String, Object> bodyParts = new LinkedHashMap<>();
|
||||
bodyParts.put("Text", Map.of("Data", email.textBody(), "Charset", "UTF-8"));
|
||||
email
|
||||
.htmlBody()
|
||||
.ifPresent(html -> bodyParts.put("Html", Map.of("Data", html, "Charset", "UTF-8")));
|
||||
simple.put("Body", bodyParts);
|
||||
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("FromEmailAddress", properties.senderIdentity());
|
||||
payload.put("Destination", Map.of("ToAddresses", java.util.List.of(recipientAddress)));
|
||||
payload.put("Content", Map.of("Simple", simple));
|
||||
payload.put("Destination", Map.of("ToAddresses", List.of(recipientAddress)));
|
||||
payload.put("Content", content(submission, email, recipientAddress, attachments));
|
||||
properties.configurationSet().ifPresent(name -> payload.put("ConfigurationSetName", name));
|
||||
|
||||
byte[] body =
|
||||
@@ -83,4 +127,83 @@ public final class SesRequestMapper {
|
||||
body,
|
||||
properties.timeout());
|
||||
}
|
||||
|
||||
/**
|
||||
* The content shape this email needs.
|
||||
*
|
||||
* <p>The decision is made from what the content <em>declares</em>, not from what was handed in:
|
||||
* an email that declares an attachment and arrives with fewer than it declared must not fall back
|
||||
* to {@code Simple}, because that is precisely the send that leaves the document behind and
|
||||
* reports success.
|
||||
*/
|
||||
private Map<String, Object> content(
|
||||
ProviderSubmission submission,
|
||||
EmailContent email,
|
||||
String recipientAddress,
|
||||
List<ResolvedAttachment> attachments) {
|
||||
if (email.attachments().isEmpty()) {
|
||||
return Map.of("Simple", simple(email));
|
||||
}
|
||||
if (attachments.size() != email.attachments().size()) {
|
||||
throw rejection(
|
||||
new IllegalStateException(
|
||||
"the submission declares "
|
||||
+ email.attachments().size()
|
||||
+ " attachments and "
|
||||
+ attachments.size()
|
||||
+ " were opened"));
|
||||
}
|
||||
return Map.of("Raw", Map.of("Data", rawMessage(submission, recipientAddress, attachments)));
|
||||
}
|
||||
|
||||
/** The MIME message, base64 encoded as the SES v2 JSON binding requires for a blob. */
|
||||
private String rawMessage(
|
||||
ProviderSubmission submission,
|
||||
String recipientAddress,
|
||||
List<ResolvedAttachment> attachments) {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
try {
|
||||
mimeFactory
|
||||
.create(submission, recipientAddress, properties.senderIdentity(), attachments)
|
||||
.writeTo(buffer);
|
||||
} catch (IOException | MessagingException failure) {
|
||||
// The cause, never the content: which step of assembly failed is what an operator needs, and
|
||||
// the bytes it failed on are the recipient's document.
|
||||
throw rejection(failure);
|
||||
}
|
||||
|
||||
byte[] message = buffer.toByteArray();
|
||||
if (message.length > MAX_MESSAGE_BYTES) {
|
||||
throw new ProviderPayloadLimitException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD));
|
||||
}
|
||||
return Base64.getEncoder().encodeToString(message);
|
||||
}
|
||||
|
||||
private static Map<String, Object> simple(EmailContent email) {
|
||||
Map<String, Object> simple = new LinkedHashMap<>();
|
||||
simple.put("Subject", Map.of("Data", email.subject(), "Charset", "UTF-8"));
|
||||
Map<String, Object> bodyParts = new LinkedHashMap<>();
|
||||
bodyParts.put("Text", Map.of("Data", email.textBody(), "Charset", "UTF-8"));
|
||||
email
|
||||
.htmlBody()
|
||||
.ifPresent(html -> bodyParts.put("Html", Map.of("Data", html, "Charset", "UTF-8")));
|
||||
simple.put("Body", bodyParts);
|
||||
return simple;
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal, carrying what caused it.
|
||||
*
|
||||
* <p>One descriptor for every shaping failure — it is what ends up on the delivery row, and a
|
||||
* per-check code there is a metric-cardinality problem. The cause is what tells an operator which
|
||||
* check fired.
|
||||
*/
|
||||
private static NotificationValidationException rejection(Throwable cause) {
|
||||
return new NotificationValidationException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD),
|
||||
cause);
|
||||
}
|
||||
}
|
||||
|
||||
+35
-1
@@ -10,6 +10,8 @@ import dev.caskeleton.application.notification.platform.provider.ResolvedAttachm
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@@ -72,8 +74,16 @@ public final class SmtpMimeMessageFactory {
|
||||
helper.setText(email.textBody(), false);
|
||||
}
|
||||
for (ResolvedAttachment attachment : attachments) {
|
||||
byte[] bytes = read(attachment);
|
||||
// A source that can be read again, not the resolver's one-shot stream. JavaMail reads an
|
||||
// attachment twice — once to choose the part's transfer encoding, once to write the part —
|
||||
// and the second read of an already drained stream returned nothing. The part that went out
|
||||
// announced a filename and carried no bytes, so the mail arrived with an empty attachment
|
||||
// and the attempt was still recorded as accepted.
|
||||
helper.addAttachment(
|
||||
attachment.displayName(), () -> attachment.content(), attachment.contentType());
|
||||
attachment.displayName(),
|
||||
() -> new ByteArrayInputStream(bytes),
|
||||
attachment.contentType());
|
||||
}
|
||||
for (var header : email.options().approvedHeaders().entrySet()) {
|
||||
requireHeaderSafe(header.getKey(), "approved header name");
|
||||
@@ -86,6 +96,30 @@ public final class SmtpMimeMessageFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the attachment into memory once.
|
||||
*
|
||||
* <p>Bounded by the size the integrity guard already pinned against the reference, and the read
|
||||
* is checked against it: a stream that turns out to be longer or shorter than the size that was
|
||||
* verified is not the content that was approved, whatever its reported digest said.
|
||||
*/
|
||||
private static byte[] read(ResolvedAttachment attachment) {
|
||||
byte[] bytes;
|
||||
try {
|
||||
bytes = attachment.content().readAllBytes();
|
||||
} catch (IOException unreadable) {
|
||||
throw rejection(unreadable);
|
||||
}
|
||||
if (bytes.length != attachment.size()) {
|
||||
// The count, never the bytes: how much was read is diagnostic, what was read is the
|
||||
// recipient's document.
|
||||
throw rejection(
|
||||
new IllegalStateException(
|
||||
"attachment declared " + attachment.size() + " bytes and read " + bytes.length));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static void requireHeaderSafe(String value, String field) {
|
||||
if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\0') >= 0) {
|
||||
// The field name, never the value: a header-injection attempt is exactly the payload that
|
||||
|
||||
+6
-51
@@ -1,10 +1,11 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.EmailAttachments;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
|
||||
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
|
||||
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
|
||||
@@ -38,8 +39,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid
|
||||
private final ContactPointProtector protector;
|
||||
private final SmtpProviderProperties properties;
|
||||
private final Executor executor;
|
||||
private final dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard
|
||||
attachmentGuard;
|
||||
private final AttachmentIntegrityGuard attachmentGuard;
|
||||
|
||||
public SmtpNotificationProviderAdapter(
|
||||
SmtpDispatch dispatch,
|
||||
@@ -48,8 +48,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid
|
||||
ContactPointProtector protector,
|
||||
SmtpProviderProperties properties,
|
||||
Executor executor,
|
||||
dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard
|
||||
attachmentGuard) {
|
||||
AttachmentIntegrityGuard attachmentGuard) {
|
||||
this.dispatch = Objects.requireNonNull(dispatch, "dispatch");
|
||||
this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory");
|
||||
this.classifier = Objects.requireNonNull(classifier, "classifier");
|
||||
@@ -96,7 +95,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid
|
||||
// Resolved, verified and closed around the send. The factory was handed List.of() whatever the
|
||||
// content asked for, so an email with attachments went out without them — the caller was told
|
||||
// it was accepted, and the recipient received a message missing the thing it was about.
|
||||
List<ResolvedAttachment> opened = resolve(submission);
|
||||
List<ResolvedAttachment> opened = EmailAttachments.open(attachmentGuard, submission);
|
||||
try {
|
||||
dispatch.send(
|
||||
mimeFactory.create(
|
||||
@@ -105,51 +104,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid
|
||||
} catch (SmtpDispatchException failure) {
|
||||
return classifier.classify(failure, elapsedSince(startedNanos));
|
||||
} finally {
|
||||
// Closed on every path. A resolver hands back an open stream, and a failed send is exactly
|
||||
// when a leaked one goes unnoticed.
|
||||
opened.forEach(SmtpNotificationProviderAdapter::closeQuietly);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves and verifies every attachment the content declares.
|
||||
*
|
||||
* <p>The integrity guard runs before the provider call, not after: a digest or size that does not
|
||||
* match what the caller declared means the bytes are not the bytes that were approved, and
|
||||
* discovering that after the mail has left is discovering it too late.
|
||||
*/
|
||||
private List<ResolvedAttachment> resolve(ProviderSubmission submission) {
|
||||
if (!(submission.content().content() instanceof EmailContent email)
|
||||
|| email.attachments().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<ResolvedAttachment> resolved = new java.util.ArrayList<>(email.attachments().size());
|
||||
try {
|
||||
for (var reference : email.attachments()) {
|
||||
// The guard resolves and verifies size and digest in one step, so an attachment whose
|
||||
// bytes are not the approved bytes never reaches the MIME factory.
|
||||
resolved.add(
|
||||
attachmentGuard.resolve(
|
||||
reference,
|
||||
new dev.caskeleton.application.notification.platform.provider
|
||||
.AttachmentAccessContext(
|
||||
new dev.caskeleton.application.notification.platform.api.TenantId(
|
||||
submission.profile().environment()),
|
||||
submission.attemptId())));
|
||||
}
|
||||
return List.copyOf(resolved);
|
||||
} catch (RuntimeException failure) {
|
||||
// Everything already opened is closed before the failure propagates.
|
||||
resolved.forEach(SmtpNotificationProviderAdapter::closeQuietly);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeQuietly(ResolvedAttachment attachment) {
|
||||
try {
|
||||
attachment.close();
|
||||
} catch (Exception ignored) {
|
||||
// A stream that will not close is not a reason to change the send's outcome.
|
||||
EmailAttachments.closeAll(opened);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-1
@@ -52,7 +52,7 @@ public final class TwilioCallbackAdapter implements ProviderCallbackAdapter {
|
||||
properties.canonicalCallbackUrl(),
|
||||
parameters,
|
||||
request.header("x-twilio-signature").orElse(null),
|
||||
secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material());
|
||||
signingKey());
|
||||
return valid
|
||||
? CallbackVerificationResult.valid(new VerifiedCallback(request, parameters))
|
||||
: CallbackVerificationResult.invalid("TWILIO_SIGNATURE_MISMATCH");
|
||||
@@ -65,6 +65,26 @@ public final class TwilioCallbackAdapter implements ProviderCallbackAdapter {
|
||||
return List.of(normalizer.normalize(callback.canonicalParameters(), occurredAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* The key this profile's callbacks are signed with.
|
||||
*
|
||||
* <p>Verification used the platform's one current callback signing key, so every Twilio profile
|
||||
* in a deployment shared it: a subaccount whose token leaked could forge status callbacks for any
|
||||
* other, and a profile could not be rotated on its own. {@code callbackSigningKeyRef} is the
|
||||
* profile's own reference, and a reference naming a key issued for another purpose is a
|
||||
* configuration fault rather than a signature that quietly never matches.
|
||||
*/
|
||||
private byte[] signingKey() {
|
||||
var key = secrets.keyById(properties.callbackSigningKeyRef());
|
||||
if (key.purpose() != SecretPurpose.CALLBACK_SIGNING) {
|
||||
throw new IllegalStateException(
|
||||
"twilio profile for account "
|
||||
+ properties.accountSid()
|
||||
+ " names a key that is not a callback signing key");
|
||||
}
|
||||
return key.material();
|
||||
}
|
||||
|
||||
private static Map<String, String> parseForm(byte[] body) {
|
||||
Map<String, String> parameters = new LinkedHashMap<>();
|
||||
String raw = new String(body, StandardCharsets.UTF_8);
|
||||
|
||||
+11
@@ -12,6 +12,10 @@ import java.util.Optional;
|
||||
* request. Twilio signs the URL it called, and a reverse proxy that rewrites scheme or host makes a
|
||||
* server-side reconstruction disagree with the signature — the most common cause of "valid webhook,
|
||||
* failed verification".
|
||||
*
|
||||
* <p>{@code callbackSigningKeyRef} is this profile's own signing key, mirroring the profile's
|
||||
* {@code callback-signing-secret-ref} setting. Verification used the platform's single current
|
||||
* callback signing key, so every profile shared one secret.
|
||||
*/
|
||||
public record TwilioProviderProperties(
|
||||
URI endpoint,
|
||||
@@ -19,6 +23,7 @@ public record TwilioProviderProperties(
|
||||
Optional<String> messagingServiceSid,
|
||||
Optional<String> fromNumber,
|
||||
String canonicalCallbackUrl,
|
||||
String callbackSigningKeyRef,
|
||||
Duration timeout,
|
||||
Duration maxReconciliationAge) {
|
||||
|
||||
@@ -28,11 +33,17 @@ public record TwilioProviderProperties(
|
||||
Objects.requireNonNull(messagingServiceSid, "messagingServiceSid");
|
||||
Objects.requireNonNull(fromNumber, "fromNumber");
|
||||
Objects.requireNonNull(canonicalCallbackUrl, "canonicalCallbackUrl");
|
||||
Objects.requireNonNull(callbackSigningKeyRef, "callbackSigningKeyRef");
|
||||
Objects.requireNonNull(timeout, "timeout");
|
||||
Objects.requireNonNull(maxReconciliationAge, "maxReconciliationAge");
|
||||
if (accountSid.isBlank()) {
|
||||
throw new IllegalArgumentException("accountSid");
|
||||
}
|
||||
if (callbackSigningKeyRef.isBlank()) {
|
||||
// Blank would fall back to whatever key is current, which is the platform-wide sharing this
|
||||
// reference exists to end.
|
||||
throw new IllegalArgumentException("callbackSigningKeyRef");
|
||||
}
|
||||
if (messagingServiceSid.isEmpty() == fromNumber.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"exactly one of messagingServiceSid or fromNumber must be configured");
|
||||
|
||||
+15
-10
@@ -5,13 +5,12 @@ import dev.caskeleton.adapter.outbound.notification.platform.provider.http.Notif
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
|
||||
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot;
|
||||
import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability;
|
||||
import dev.caskeleton.application.notification.platform.provider.ReconciliationResult;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
@@ -37,19 +36,19 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab
|
||||
private final NotificationHttpGateway gateway;
|
||||
private final TwilioProviderProperties properties;
|
||||
private final TwilioStatusNormalizer normalizer;
|
||||
private final SecretMaterialProvider secrets;
|
||||
private final ProviderCredentialManager credentials;
|
||||
private final Clock clock;
|
||||
|
||||
public TwilioReconciliationCapability(
|
||||
NotificationHttpGateway gateway,
|
||||
TwilioProviderProperties properties,
|
||||
TwilioStatusNormalizer normalizer,
|
||||
SecretMaterialProvider secrets,
|
||||
ProviderCredentialManager credentials,
|
||||
Clock clock) {
|
||||
this.gateway = Objects.requireNonNull(gateway, "gateway");
|
||||
this.properties = Objects.requireNonNull(properties, "properties");
|
||||
this.normalizer = Objects.requireNonNull(normalizer, "normalizer");
|
||||
this.secrets = Objects.requireNonNull(secrets, "secrets");
|
||||
this.credentials = Objects.requireNonNull(credentials, "credentials");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
@@ -75,7 +74,8 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab
|
||||
}
|
||||
|
||||
try {
|
||||
NotificationHttpResponse response = gateway.exchange(statusRequest(messageSid.get()));
|
||||
NotificationHttpResponse response =
|
||||
gateway.exchange(statusRequest(attempt, messageSid.get()));
|
||||
if (!response.isSuccessful()) {
|
||||
return CompletableFuture.completedFuture(
|
||||
new ReconciliationResult.Failed(
|
||||
@@ -111,14 +111,19 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab
|
||||
};
|
||||
}
|
||||
|
||||
private NotificationHttpRequest statusRequest(String messageSid) {
|
||||
String credentials =
|
||||
private NotificationHttpRequest statusRequest(
|
||||
DeliveryAttemptSnapshot attempt, String messageSid) {
|
||||
// The credential the attempt was made with, not whichever one is current. A status query is a
|
||||
// question about work that already happened, and asking it with a newer generation's token
|
||||
// fails once a rotation has landed — precisely when reconciliation matters most.
|
||||
String authorization =
|
||||
Base64.getEncoder()
|
||||
.encodeToString(
|
||||
(properties.accountSid()
|
||||
+ ":"
|
||||
+ new String(
|
||||
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(),
|
||||
credentials.materialFor(
|
||||
attempt.providerProfileId(), attempt.credentialGeneration()),
|
||||
StandardCharsets.UTF_8))
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
@@ -131,7 +136,7 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab
|
||||
+ "/Messages/"
|
||||
+ messageSid
|
||||
+ ".json"),
|
||||
JdkNotificationHttpGateway.headers(Map.of("authorization", "Basic " + credentials)),
|
||||
JdkNotificationHttpGateway.headers(Map.of("authorization", "Basic " + authorization)),
|
||||
new byte[0],
|
||||
properties.timeout());
|
||||
}
|
||||
|
||||
+9
-6
@@ -4,6 +4,7 @@ import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderRe
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
@@ -15,8 +16,6 @@ import dev.caskeleton.application.notification.platform.provider.ProviderSubmiss
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
|
||||
import dev.caskeleton.application.notification.platform.security.AccessContext;
|
||||
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -40,19 +39,19 @@ public final class TwilioSmsProviderAdapter implements NotificationProviderAdapt
|
||||
private final TwilioRequestMapper mapper;
|
||||
private final TwilioFailureClassifier classifier;
|
||||
private final ContactPointProtector protector;
|
||||
private final SecretMaterialProvider secrets;
|
||||
private final ProviderCredentialManager credentials;
|
||||
|
||||
public TwilioSmsProviderAdapter(
|
||||
NotificationHttpGateway gateway,
|
||||
TwilioRequestMapper mapper,
|
||||
TwilioFailureClassifier classifier,
|
||||
ContactPointProtector protector,
|
||||
SecretMaterialProvider secrets) {
|
||||
ProviderCredentialManager credentials) {
|
||||
this.gateway = Objects.requireNonNull(gateway, "gateway");
|
||||
this.mapper = Objects.requireNonNull(mapper, "mapper");
|
||||
this.classifier = Objects.requireNonNull(classifier, "classifier");
|
||||
this.protector = Objects.requireNonNull(protector, "protector");
|
||||
this.secrets = Objects.requireNonNull(secrets, "secrets");
|
||||
this.credentials = Objects.requireNonNull(credentials, "credentials");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -93,7 +92,11 @@ public final class TwilioSmsProviderAdapter implements NotificationProviderAdapt
|
||||
mapper.map(
|
||||
submission,
|
||||
phone.e164(),
|
||||
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material());
|
||||
// This profile's auth token at the generation the submission was planned against. One
|
||||
// platform-wide provider credential meant a leak of one Twilio account's token was a
|
||||
// leak of every profile's, whichever provider they belonged to.
|
||||
credentials.materialFor(
|
||||
submission.profile().profileId(), submission.profile().credentialGeneration()));
|
||||
|
||||
try {
|
||||
NotificationHttpResponse response = gateway.exchange(request);
|
||||
|
||||
+48
-3
@@ -10,6 +10,8 @@ import dev.caskeleton.adapter.outbound.notification.platform.template.Notificati
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
|
||||
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
|
||||
@@ -82,10 +84,23 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro
|
||||
return Set.of(Channel.WEBHOOK);
|
||||
}
|
||||
|
||||
/** The largest body this adapter will put on the wire. */
|
||||
public static final long MAX_BODY_BYTES = 1_000_000L;
|
||||
|
||||
@Override
|
||||
public ProviderCapabilities capabilities() {
|
||||
return new ProviderCapabilities(
|
||||
false, false, false, false, false, false, false, false, 1, 1_000_000L, Duration.ofHours(1));
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
MAX_BODY_BYTES,
|
||||
Duration.ofHours(1));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -130,6 +145,14 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro
|
||||
NotificationJsonMapper.mapper()
|
||||
.writeValueAsString(envelope)
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
// Measured on the bytes that will be sent. The capability declared a ceiling and nothing
|
||||
// enforced it, so an oversized body was discovered by the receiver rejecting it — after the
|
||||
// request had been made, which for a webhook is after the receiver may already have acted.
|
||||
if (body.length > MAX_BODY_BYTES) {
|
||||
throw new ProviderPayloadLimitException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD));
|
||||
}
|
||||
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put("content-type", "application/json");
|
||||
@@ -139,8 +162,7 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro
|
||||
WebhookSignatureStrategy.TIMESTAMP_HEADER, Long.toString(timestamp.getEpochSecond()));
|
||||
headers.put(
|
||||
WebhookSignatureStrategy.SIGNATURE_HEADER,
|
||||
signatures.sign(
|
||||
body, timestamp, secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material()));
|
||||
signatures.sign(body, timestamp, signingKey(subscription)));
|
||||
}
|
||||
|
||||
NotificationHttpRequest request =
|
||||
@@ -182,6 +204,29 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The key a subscription's signature is computed with.
|
||||
*
|
||||
* <p>{@code signingKeyRef} was read only to decide whether to sign at all, and the signature was
|
||||
* then computed with the platform's current callback signing key. Every trusted subscription
|
||||
* therefore shared one secret: a receiver holding its own key could verify — and forge —
|
||||
* deliveries meant for any other, and rotating one subscription's key rotated all of them.
|
||||
*
|
||||
* @throws IllegalStateException if the reference names a key that is not a callback signing key,
|
||||
* which is a configuration fault and is raised before the request is made rather than
|
||||
* producing a signature the receiver will reject
|
||||
*/
|
||||
private byte[] signingKey(WebhookSubscription subscription) {
|
||||
var key = secrets.keyById(subscription.signingKeyRef().orElseThrow());
|
||||
if (key.purpose() != SecretPurpose.CALLBACK_SIGNING) {
|
||||
throw new IllegalStateException(
|
||||
"webhook subscription "
|
||||
+ subscription.subscriptionId()
|
||||
+ " names a key that is not a callback signing key");
|
||||
}
|
||||
return key.material();
|
||||
}
|
||||
|
||||
/**
|
||||
* The receiver's own backoff hint, when it sent a usable one.
|
||||
*
|
||||
|
||||
+25
-1
@@ -11,6 +11,10 @@ import java.util.Optional;
|
||||
* <p>{@code trusted} decides which gateway carries the call. A trusted subscription is operator
|
||||
* configured and may use platform credentials; a dynamic one comes from user input and must not
|
||||
* inherit anything, because that is how a webhook feature becomes an SSRF credential-relay.
|
||||
*
|
||||
* <p>It decides the loopback allowance for the same reason. An operator naming a local endpoint is
|
||||
* describing their own deployment; a client naming one is asking the platform to deliver a message
|
||||
* body to an interface the client cannot otherwise reach.
|
||||
*/
|
||||
public record WebhookSubscription(
|
||||
String subscriptionId, URI target, boolean trusted, Optional<String> signingKeyRef) {
|
||||
@@ -22,7 +26,27 @@ public record WebhookSubscription(
|
||||
if (subscriptionId.isBlank()) {
|
||||
throw new IllegalArgumentException("subscriptionId");
|
||||
}
|
||||
NotificationEndpoints.requireSecureOrLoopback(target, "webhook target");
|
||||
// requireExternallyRoutable, not requireSecureOrLoopback. The scheme check accepted any HTTPS
|
||||
// URL, so `https://169.254.169.254/` — the cloud metadata service — and every RFC 1918 address
|
||||
// passed. The stronger guard was written for exactly this call site and then called from
|
||||
// nowhere: it existed, its own tests were green, and the two sites it was written for kept the
|
||||
// weaker check.
|
||||
//
|
||||
// The loopback allowance is `trusted`, not a constant. It was `true` for every caller, which
|
||||
// left one case open: a client-supplied target naming `localhost` reached the loopback
|
||||
// interface. Closing it was deferred on the grounds that the allowance had to become a decision
|
||||
// the caller states and no caller existed to state it — but the decision is this record's first
|
||||
// boolean, and two lines below it already decides whether the target may inherit a platform
|
||||
// signing key. A subscription an operator configured may address a local endpoint, because the
|
||||
// operator profiles and the contract harness do exactly that. One that came from user input may
|
||||
// not, for the same reason it may not inherit credentials: it is not the deployment's own
|
||||
// address to name.
|
||||
//
|
||||
// What remains open is narrower and belongs to the guard, not here: the target is resolved once
|
||||
// at construction and re-resolved independently by the HTTP client, so a name that changes its
|
||||
// answer between the two is refused only if the first lookup already shows an internal address.
|
||||
// NTF-012, docs/reviews/2026-08-14-notification-module-code-review.md.
|
||||
NotificationEndpoints.requireExternallyRoutable(target, "webhook target", trusted);
|
||||
if (!trusted && signingKeyRef.isPresent()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a dynamic target may not be paired with a platform signing key");
|
||||
|
||||
+32
-38
@@ -4,18 +4,16 @@ import dev.caskeleton.adapter.outbound.notification.platform.template.Notificati
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.callback.CallbackPayloadProtectionPort;
|
||||
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
|
||||
import dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
@@ -25,35 +23,36 @@ import javax.crypto.spec.SecretKeySpec;
|
||||
* provider actually sent — but it routinely contains addresses and message metadata, so it is
|
||||
* encrypted and truncated rather than stored as received.
|
||||
*
|
||||
* <p>The nonce is prefixed to the ciphertext so a rotation does not need a second column, and the
|
||||
* fingerprint is keyed so that providers without an event id still get collision-resistant,
|
||||
* <p>The stored bytes are the same versioned, key-identified envelope the notification payload
|
||||
* column uses, and for the same reason. This class used to write a bare nonce and ciphertext: the
|
||||
* day the payload encryption key rotated, every retained callback became unreadable and nothing in
|
||||
* the row could say which key it had needed. A retention format whose whole justification is later
|
||||
* diagnosis has to survive the rotation that happens in between.
|
||||
*
|
||||
* <p>The fingerprint is keyed so that providers without an event id still get collision-resistant,
|
||||
* non-enumerable duplicate detection.
|
||||
*/
|
||||
public final class AesGcmCallbackPayloadProtection implements CallbackPayloadProtectionPort {
|
||||
|
||||
private static final int NONCE_BYTES = 12;
|
||||
private static final int TAG_BITS = 128;
|
||||
|
||||
private final SecretMaterialProvider secrets;
|
||||
private final SecureRandom random;
|
||||
private final NotificationPayloadProtection payloads;
|
||||
private final int maxRetainedBytes;
|
||||
|
||||
public AesGcmCallbackPayloadProtection(SecretMaterialProvider secrets, int maxRetainedBytes) {
|
||||
this(secrets, new SecureRandom(), maxRetainedBytes);
|
||||
}
|
||||
|
||||
AesGcmCallbackPayloadProtection(
|
||||
SecretMaterialProvider secrets, SecureRandom random, int maxRetainedBytes) {
|
||||
public AesGcmCallbackPayloadProtection(
|
||||
SecretMaterialProvider secrets,
|
||||
NotificationPayloadProtection payloads,
|
||||
int maxRetainedBytes) {
|
||||
this.secrets = Objects.requireNonNull(secrets, "secrets");
|
||||
this.random = Objects.requireNonNull(random, "random");
|
||||
this.payloads = Objects.requireNonNull(payloads, "payloads");
|
||||
if (maxRetainedBytes < 1) {
|
||||
throw new IllegalArgumentException("maxRetainedBytes");
|
||||
}
|
||||
if (maxRetainedBytes > MAX_PLAINTEXT_BYTES) {
|
||||
// The database check constrains the *ciphertext*, and encryption adds a 12-byte nonce and a
|
||||
// 16-byte GCM tag. Truncating the plaintext to the ciphertext bound produced a value 28 bytes
|
||||
// over it, so a callback of exactly the configured maximum was accepted by every layer above
|
||||
// and then rejected by a CHECK constraint after the provider had been told it was stored.
|
||||
// The database check constrains the *ciphertext*, and encryption adds a version byte, a key
|
||||
// id, a nonce and a GCM tag. Truncating the plaintext to the ciphertext bound produced a
|
||||
// value larger than it, so a callback of exactly the configured maximum was accepted by
|
||||
// every layer above and then rejected by a CHECK constraint after the provider had been told
|
||||
// it was stored.
|
||||
throw new IllegalArgumentException(
|
||||
"callback retention of "
|
||||
+ maxRetainedBytes
|
||||
@@ -75,8 +74,15 @@ public final class AesGcmCallbackPayloadProtection implements CallbackPayloadPro
|
||||
*/
|
||||
public static final int MAX_CIPHERTEXT_BYTES = 65_536;
|
||||
|
||||
/** The nonce and GCM tag every encryption adds. */
|
||||
public static final int ENVELOPE_OVERHEAD_BYTES = NONCE_BYTES + TAG_BITS / 8;
|
||||
/**
|
||||
* The most the envelope adds: version, key id, nonce and GCM tag.
|
||||
*
|
||||
* <p>Reserved at the largest key id the envelope allows rather than measured against the current
|
||||
* one, because a rotation to a longer id would otherwise push a body that fit yesterday past the
|
||||
* column's check constraint.
|
||||
*/
|
||||
public static final int ENVELOPE_OVERHEAD_BYTES =
|
||||
AesGcmNotificationPayloadProtection.MAX_ENVELOPE_OVERHEAD_BYTES;
|
||||
|
||||
/** The largest plaintext that still fits the column once encrypted. */
|
||||
public static final int MAX_PLAINTEXT_BYTES = MAX_CIPHERTEXT_BYTES - ENVELOPE_OVERHEAD_BYTES;
|
||||
@@ -86,22 +92,10 @@ public final class AesGcmCallbackPayloadProtection implements CallbackPayloadPro
|
||||
Objects.requireNonNull(rawBody, "rawBody");
|
||||
byte[] bounded =
|
||||
rawBody.length <= maxRetainedBytes ? rawBody : Arrays.copyOf(rawBody, maxRetainedBytes);
|
||||
byte[] nonce = new byte[NONCE_BYTES];
|
||||
random.nextBytes(nonce);
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(
|
||||
Cipher.ENCRYPT_MODE,
|
||||
new SecretKeySpec(secrets.activeKey(SecretPurpose.PAYLOAD_ENCRYPTION).material(), "AES"),
|
||||
new GCMParameterSpec(TAG_BITS, nonce));
|
||||
byte[] ciphertext = cipher.doFinal(bounded);
|
||||
byte[] stored = new byte[nonce.length + ciphertext.length];
|
||||
System.arraycopy(nonce, 0, stored, 0, nonce.length);
|
||||
System.arraycopy(ciphertext, 0, stored, nonce.length, ciphertext.length);
|
||||
return stored;
|
||||
} catch (GeneralSecurityException failure) {
|
||||
throw new IllegalStateException("callback payload encryption failed", failure);
|
||||
}
|
||||
// Delegated rather than reimplemented so the retained callback and the retained notification
|
||||
// payload are one format with one reader. The alternative is two envelopes that drift, and the
|
||||
// one that drifts is always the one nothing reads until an incident.
|
||||
return payloads.protect(bounded);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+10
@@ -50,6 +50,16 @@ public final class AesGcmNotificationPayloadProtection implements NotificationPa
|
||||
private static final int TAG_BITS = 128;
|
||||
private static final int MAX_KEY_ID_BYTES = 255;
|
||||
|
||||
/**
|
||||
* The most this envelope can add to a plaintext.
|
||||
*
|
||||
* <p>The header is variable — a key id is one to 255 bytes — so anything that has to guarantee a
|
||||
* ciphertext fits a fixed column reserves the largest header rather than the current one. A bound
|
||||
* computed from today's key id stops holding the moment a rotation picks a longer one.
|
||||
*/
|
||||
static final int MAX_ENVELOPE_OVERHEAD_BYTES =
|
||||
2 + MAX_KEY_ID_BYTES + NONCE_BYTES + TAG_BITS / Byte.SIZE;
|
||||
|
||||
private final SecretMaterialProvider secrets;
|
||||
private final SecureRandom random;
|
||||
|
||||
|
||||
+110
-1
@@ -5,6 +5,7 @@ import dev.caskeleton.application.notification.platform.security.SecretKeyMateri
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -14,6 +15,15 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
/**
|
||||
* Tracks which credential generation is current for each provider profile.
|
||||
*
|
||||
* <p>A rotation is a window, not an instant. Activating a new generation supersedes the previous
|
||||
* one but keeps it resolvable for a bounded drain period, because work planned against the old
|
||||
* generation is already in flight when the rotation lands: an attempt whose request may already
|
||||
* have reached the provider cannot simply be failed, and cannot be replayed either. Callers ask for
|
||||
* the generation their work was planned with — {@code ProviderProfileSnapshot} and {@code
|
||||
* DeliveryAttemptSnapshot} both carry it — so the window covers exactly that work and nothing else.
|
||||
* Past the window the old credential stops resolving, because a superseded credential that stays
|
||||
* usable indefinitely is not a rotation, it is two live credentials.
|
||||
*
|
||||
* <p>Two rotations are deliberately <em>not</em> handled here, because treating them as ordinary
|
||||
* credential swaps would silently lose data or delivery:
|
||||
*
|
||||
@@ -31,13 +41,46 @@ public final class ProviderCredentialManager {
|
||||
|
||||
private final SecretMaterialProvider secrets;
|
||||
private final Clock clock;
|
||||
private final Duration drainWindow;
|
||||
private final Map<ProviderProfileId, CredentialGeneration> current = new ConcurrentHashMap<>();
|
||||
private final Map<ProviderProfileId, Map<Long, Draining>> draining = new ConcurrentHashMap<>();
|
||||
|
||||
public ProviderCredentialManager(SecretMaterialProvider secrets, Clock clock) {
|
||||
/**
|
||||
* Creates the manager.
|
||||
*
|
||||
* @param secrets the key store
|
||||
* @param clock the clock the drain window is measured against
|
||||
* @param drainWindow how long a superseded generation keeps serving the work that started on it
|
||||
*/
|
||||
public ProviderCredentialManager(
|
||||
SecretMaterialProvider secrets, Clock clock, Duration drainWindow) {
|
||||
this.secrets = Objects.requireNonNull(secrets, "secrets");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
Objects.requireNonNull(drainWindow, "drainWindow");
|
||||
if (drainWindow.isNegative()) {
|
||||
throw new IllegalArgumentException("drainWindow");
|
||||
}
|
||||
this.drainWindow = drainWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default drain window: long enough for an in-flight attempt, short enough to be a window.
|
||||
*/
|
||||
public static final Duration DEFAULT_DRAIN_WINDOW = Duration.ofMinutes(15);
|
||||
|
||||
/**
|
||||
* Creates the manager with the default drain window.
|
||||
*
|
||||
* @param secrets the key store
|
||||
* @param clock the clock the drain window is measured against
|
||||
*/
|
||||
public ProviderCredentialManager(SecretMaterialProvider secrets, Clock clock) {
|
||||
this(secrets, clock, DEFAULT_DRAIN_WINDOW);
|
||||
}
|
||||
|
||||
/** A superseded generation and the instant it stops being usable. */
|
||||
private record Draining(CredentialGeneration generation, Instant usableUntil) {}
|
||||
|
||||
/**
|
||||
* Record the generation a profile starts on.
|
||||
*
|
||||
@@ -63,10 +106,76 @@ public final class ProviderCredentialManager {
|
||||
if (existing != null && !generation.supersedes(existing)) {
|
||||
throw new IllegalArgumentException("generation does not supersede the active one");
|
||||
}
|
||||
if (existing != null) {
|
||||
// Superseded, not deleted. An attempt that was planned against the previous generation
|
||||
// is already in flight when the rotation lands, and retiring the credential the instant
|
||||
// the new one arrives fails exactly that work — the requests nobody can replay, because
|
||||
// the provider may already have acted on them. The window bounds it: an old credential
|
||||
// that stays usable forever is not a rotation, it is two live credentials.
|
||||
retire(profileId, existing, activatedAt);
|
||||
}
|
||||
return generation.activatedAt(activatedAt);
|
||||
});
|
||||
}
|
||||
|
||||
private void retire(
|
||||
ProviderProfileId profileId, CredentialGeneration superseded, Instant supersededAt) {
|
||||
draining
|
||||
.computeIfAbsent(profileId, id -> new ConcurrentHashMap<>())
|
||||
.put(superseded.generation(), new Draining(superseded, supersededAt.plus(drainWindow)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential material for one profile at the generation the work was planned against.
|
||||
*
|
||||
* <p>Every adapter resolved {@code activeKey(PROVIDER_CREDENTIAL)} instead: one credential for
|
||||
* every profile in the deployment, so a leak of one provider account's key was a leak of all of
|
||||
* them, and a per-profile rotation was not expressible at all. The generation is not a parameter
|
||||
* a caller invents — {@code ProviderProfileSnapshot.credentialGeneration()} and {@code
|
||||
* DeliveryAttemptSnapshot.credentialGeneration()} already carry the number the work was planned
|
||||
* with, which is what makes the drain window mean something rather than being a grace period
|
||||
* nobody claims.
|
||||
*
|
||||
* @param profileId the profile the work belongs to
|
||||
* @param generation the generation the work was planned against
|
||||
* @return the material
|
||||
* @throws IllegalStateException if the profile has no active generation, or the requested one is
|
||||
* neither current nor still inside its drain window
|
||||
*/
|
||||
public byte[] materialFor(ProviderProfileId profileId, long generation) {
|
||||
Objects.requireNonNull(profileId, "profileId");
|
||||
CredentialGeneration active = current.get(profileId);
|
||||
if (active == null) {
|
||||
throw new IllegalStateException(
|
||||
"provider profile " + profileId.value() + " has no activated credential generation");
|
||||
}
|
||||
if (active.generation() == generation) {
|
||||
return material(active).material();
|
||||
}
|
||||
Draining retired = draining.getOrDefault(profileId, Map.of()).get(generation);
|
||||
if (retired == null) {
|
||||
throw new IllegalStateException(
|
||||
"provider profile "
|
||||
+ profileId.value()
|
||||
+ " has no credential generation "
|
||||
+ generation
|
||||
+ "; the active generation is "
|
||||
+ active.generation());
|
||||
}
|
||||
if (!clock.instant().isBefore(retired.usableUntil())) {
|
||||
// Dropped rather than served: past the window, work still asking for the old generation is
|
||||
// work that has been stuck long enough that using a retired credential is the larger risk.
|
||||
draining.getOrDefault(profileId, Map.of()).remove(generation);
|
||||
throw new IllegalStateException(
|
||||
"credential generation "
|
||||
+ generation
|
||||
+ " for provider profile "
|
||||
+ profileId.value()
|
||||
+ " finished draining; it is no longer usable");
|
||||
}
|
||||
return material(retired.generation()).material();
|
||||
}
|
||||
|
||||
/** Current generation of a profile. */
|
||||
public Optional<CredentialGeneration> current(ProviderProfileId profileId) {
|
||||
return Optional.ofNullable(current.get(Objects.requireNonNull(profileId, "profileId")));
|
||||
|
||||
+8
-4
@@ -10,7 +10,6 @@ import java.util.Map;
|
||||
* renderer per engine is how two implementations end up computing different digests for the same
|
||||
* template, which silently breaks the retry equality the digest exists to prove.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface NotificationTemplateEngine {
|
||||
|
||||
/**
|
||||
@@ -27,12 +26,17 @@ public interface NotificationTemplateEngine {
|
||||
* <p>The mode is required rather than inferred: the same template text is safe in a text part and
|
||||
* dangerous in an HTML one, and only the caller knows which it is filling.
|
||||
*
|
||||
* <p>Abstract, not a {@code default} that forwards to the single-argument overload. It was that
|
||||
* default, and one of the two engines never overrode it — so selecting that engine silently
|
||||
* dropped every slot to the unescaped path: a subject could carry CR/LF, a deep link could carry
|
||||
* a {@code javascript:} scheme, and plain text had its ampersands HTML-escaped on the wire. A
|
||||
* default that discards its own argument is not a fallback; it is the rule not applying, and the
|
||||
* engine that skipped it looked complete because the interface compiled.
|
||||
*
|
||||
* @param mode what the rendered value will become
|
||||
* @param source the template text
|
||||
* @param variables the values to substitute
|
||||
* @return the rendered slot
|
||||
*/
|
||||
default String render(TemplateSlotMode mode, String source, Map<String, Object> variables) {
|
||||
return render(source, variables);
|
||||
}
|
||||
String render(TemplateSlotMode mode, String source, Map<String, Object> variables);
|
||||
}
|
||||
|
||||
+3
-74
@@ -61,79 +61,8 @@ public final class PlaceholderTemplateEngine implements NotificationTemplateEngi
|
||||
|
||||
/** Escapes one substituted value for its destination. */
|
||||
private static String escape(TemplateSlotMode mode, String value) {
|
||||
return switch (mode) {
|
||||
case TEXT -> value;
|
||||
case SUBJECT -> requireSingleLine(value);
|
||||
case HTML_TEXT -> escapeHtml(value);
|
||||
case URI -> requireAllowedScheme(value);
|
||||
};
|
||||
// The rules moved to TemplateSlotPolicy so the other engine could reach them. They were private
|
||||
// here, which is why that engine had none.
|
||||
return TemplateSlotPolicy.escape(mode, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a value that would split a header.
|
||||
*
|
||||
* <p>A carriage return or newline in a subject is header injection: everything after it is read
|
||||
* as a new header by the receiving agent.
|
||||
*/
|
||||
private static String requireSingleLine(String value) {
|
||||
for (int index = 0; index < value.length(); index++) {
|
||||
if (value.charAt(index) < 0x20) {
|
||||
throw new TemplateRenderingException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.TEMPLATE_RENDERING_FAILED,
|
||||
FailureCategory.TEMPLATE_FAILURE));
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes for HTML text and attribute content.
|
||||
*
|
||||
* <p>Quotes included, because a value substituted inside an attribute can otherwise close it and
|
||||
* start an event handler — {@code " onerror="} needs no angle bracket at all.
|
||||
*/
|
||||
private static String escapeHtml(String value) {
|
||||
StringBuilder escaped = new StringBuilder(value.length() + 16);
|
||||
for (int index = 0; index < value.length(); index++) {
|
||||
char character = value.charAt(index);
|
||||
switch (character) {
|
||||
case '&' -> escaped.append("&");
|
||||
case '<' -> escaped.append("<");
|
||||
case '>' -> escaped.append(">");
|
||||
case '"' -> escaped.append(""");
|
||||
case '\'' -> escaped.append("'");
|
||||
default -> escaped.append(character);
|
||||
}
|
||||
}
|
||||
return escaped.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows only schemes a notification may legitimately link to.
|
||||
*
|
||||
* <p>{@code javascript:} in a link is script execution; {@code data:} is an arbitrary document
|
||||
* the platform vouches for; {@code file:} points at the reader's own machine. The slot used to be
|
||||
* parsed as a URI and otherwise accepted, and parsing succeeds for all three.
|
||||
*/
|
||||
private static String requireAllowedScheme(String value) {
|
||||
String normalized = value.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
boolean allowed =
|
||||
ALLOWED_URI_SCHEMES.stream().anyMatch(scheme -> normalized.startsWith(scheme + ":"));
|
||||
if (!allowed) {
|
||||
throw new TemplateRenderingException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.TEMPLATE_RENDERING_FAILED, FailureCategory.TEMPLATE_FAILURE));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The schemes a rendered link may use.
|
||||
*
|
||||
* <p>HTTPS, and the application's own deep-link scheme. Plain HTTP is absent deliberately: a link
|
||||
* in a notification is followed by a person who has no way to check it.
|
||||
*/
|
||||
private static final java.util.Set<String> ALLOWED_URI_SCHEMES =
|
||||
java.util.Set.of("https", "caskeleton");
|
||||
}
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.template;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
|
||||
import dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What each slot mode means, in one place both engines use.
|
||||
*
|
||||
* <p>These rules lived as private helpers inside {@code PlaceholderTemplateEngine}, and the other
|
||||
* engine had no equivalent — it did not override the mode-aware render at all, so selecting it
|
||||
* dropped every slot to the unescaped path. One engine enforced the policy and the other did not
|
||||
* have access to it.
|
||||
*
|
||||
* <p>Nothing here is engine-specific: a subject may not carry a control character whoever produced
|
||||
* it, and a deep link may not use {@code javascript:} whoever rendered it.
|
||||
*/
|
||||
public final class TemplateSlotPolicy {
|
||||
|
||||
/**
|
||||
* The schemes a rendered link may use.
|
||||
*
|
||||
* <p>HTTPS, and the application's own deep-link scheme. Plain HTTP is absent deliberately: a link
|
||||
* in a notification is followed by a person who has no way to check it.
|
||||
*/
|
||||
private static final Set<String> ALLOWED_URI_SCHEMES = Set.of("https", "caskeleton");
|
||||
|
||||
private TemplateSlotPolicy() {}
|
||||
|
||||
/**
|
||||
* Escapes one substituted value for its destination.
|
||||
*
|
||||
* @param mode the slot being filled
|
||||
* @param value the value to escape
|
||||
* @return the escaped value
|
||||
*/
|
||||
public static String escape(TemplateSlotMode mode, String value) {
|
||||
return switch (mode) {
|
||||
case TEXT -> value;
|
||||
case SUBJECT -> requireSingleLine(value);
|
||||
case HTML_TEXT -> escapeHtml(value);
|
||||
case URI -> requireAllowedScheme(value);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a whole rendered slot, for an engine that substitutes internally.
|
||||
*
|
||||
* <p>An engine that does its own substitution cannot escape per value, so the guarantee is
|
||||
* applied to what it produced. For SUBJECT and URI that is the stronger statement: no control
|
||||
* character anywhere in the subject, and the finished link uses an allowed scheme. Escaping modes
|
||||
* are the engine's own job — asking it to render HTML and then escaping the result would escape
|
||||
* the operator's markup too.
|
||||
*
|
||||
* @param mode the slot that was filled
|
||||
* @param rendered the engine's output
|
||||
* @return the output, unchanged when it satisfies the slot
|
||||
*/
|
||||
public static String verifyRendered(TemplateSlotMode mode, String rendered) {
|
||||
return switch (mode) {
|
||||
case TEXT, HTML_TEXT -> rendered;
|
||||
case SUBJECT -> requireSingleLine(rendered);
|
||||
case URI -> requireAllowedScheme(rendered);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a value that would split a header.
|
||||
*
|
||||
* <p>A carriage return or newline in a subject is header injection: everything after it is read
|
||||
* as a new header by the receiving agent.
|
||||
*/
|
||||
private static String requireSingleLine(String value) {
|
||||
for (int index = 0; index < value.length(); index++) {
|
||||
if (value.charAt(index) < 0x20) {
|
||||
throw refuse();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes for HTML text and attribute content.
|
||||
*
|
||||
* <p>Quotes included, because a value substituted inside an attribute can otherwise close it and
|
||||
* start an event handler — {@code " onerror="} needs no angle bracket at all.
|
||||
*/
|
||||
private static String escapeHtml(String value) {
|
||||
StringBuilder escaped = new StringBuilder(value.length() + 16);
|
||||
for (int index = 0; index < value.length(); index++) {
|
||||
char character = value.charAt(index);
|
||||
switch (character) {
|
||||
case '&' -> escaped.append("&");
|
||||
case '<' -> escaped.append("<");
|
||||
case '>' -> escaped.append(">");
|
||||
case '"' -> escaped.append(""");
|
||||
case '\'' -> escaped.append("'");
|
||||
default -> escaped.append(character);
|
||||
}
|
||||
}
|
||||
return escaped.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows only schemes a notification may legitimately link to.
|
||||
*
|
||||
* <p>{@code javascript:} in a link is script execution; {@code data:} is an arbitrary document
|
||||
* the platform vouches for; {@code file:} points at the reader's own machine. The slot used to be
|
||||
* parsed as a URI and otherwise accepted, and parsing succeeds for all three.
|
||||
*/
|
||||
private static String requireAllowedScheme(String value) {
|
||||
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||
boolean allowed =
|
||||
ALLOWED_URI_SCHEMES.stream().anyMatch(scheme -> normalized.startsWith(scheme + ":"));
|
||||
if (!allowed) {
|
||||
throw refuse();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static TemplateRenderingException refuse() {
|
||||
return new TemplateRenderingException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.TEMPLATE_RENDERING_FAILED, FailureCategory.TEMPLATE_FAILURE));
|
||||
}
|
||||
}
|
||||
+63
-9
@@ -44,6 +44,12 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate
|
||||
|
||||
private final TemplateEngine engine;
|
||||
|
||||
/** Whether the constructor-supplied engine is the HTML one. */
|
||||
private final boolean htmlMode;
|
||||
|
||||
/** The text-mode engine, for every slot that is not HTML. */
|
||||
private final TemplateEngine textEngine = engineFor(TemplateMode.TEXT);
|
||||
|
||||
/** HTML-escaping engine, which is the safe default for email bodies. */
|
||||
public ThymeleafStringTemplateEngine() {
|
||||
this(TemplateMode.HTML);
|
||||
@@ -54,12 +60,25 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate
|
||||
*/
|
||||
public ThymeleafStringTemplateEngine(TemplateMode mode) {
|
||||
Objects.requireNonNull(mode, "mode");
|
||||
StringTemplateResolver resolver = new StringTemplateResolver();
|
||||
resolver.setTemplateMode(mode);
|
||||
resolver.setCacheable(false);
|
||||
TemplateEngine created = new TemplateEngine();
|
||||
created.setTemplateResolver(resolver);
|
||||
this.engine = created;
|
||||
this.htmlMode = mode == TemplateMode.HTML;
|
||||
this.engine = engineFor(mode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String render(TemplateSlotMode mode, String source, Map<String, Object> variables) {
|
||||
Objects.requireNonNull(mode, "mode");
|
||||
// One engine per Thymeleaf template mode, chosen by what the slot is.
|
||||
//
|
||||
// This class used to implement only the mode-less overload and inherit a `default` that threw
|
||||
// the mode away, so every slot rendered under TemplateMode.HTML: a subject could carry CR/LF, a
|
||||
// deep link could carry `javascript:`, and plain text — an SMS body — had its `&` turned into
|
||||
// `&` on the wire. The interface compiled, so nothing said the policy was not applying.
|
||||
//
|
||||
// HTML_TEXT keeps the HTML engine, which is what escapes substituted values. Everything else
|
||||
// renders as text and is then checked: Thymeleaf substitutes internally, so a per-value escape
|
||||
// is not available, and verifying the finished slot is the stronger statement anyway.
|
||||
String rendered = engineFor(mode).process(source, contextFor(source, variables));
|
||||
return TemplateSlotPolicy.verifyRendered(mode, rendered);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -68,10 +87,8 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate
|
||||
Objects.requireNonNull(variables, "variables");
|
||||
requireEveryReferencedVariable(source, variables);
|
||||
|
||||
Context context = new Context();
|
||||
variables.forEach(context::setVariable);
|
||||
try {
|
||||
return engine.process(source, context);
|
||||
return engine.process(source, contextFor(source, variables));
|
||||
} catch (RuntimeException failure) {
|
||||
// The message is dropped on purpose. Thymeleaf reports the offending expression, and a
|
||||
// template expression contains the variable it failed on — which for this platform is a
|
||||
@@ -105,4 +122,41 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The engine whose template mode matches the slot. */
|
||||
private TemplateEngine engineFor(TemplateSlotMode mode) {
|
||||
return mode == TemplateSlotMode.HTML_TEXT ? htmlEngine() : textEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTML engine.
|
||||
*
|
||||
* <p>The constructor-supplied engine when this instance was built for HTML, and a dedicated one
|
||||
* otherwise — a deployment that constructed the text engine still has HTML slots to render, and
|
||||
* rendering them as text would emit an operator's markup unescaped.
|
||||
*/
|
||||
private TemplateEngine htmlEngine() {
|
||||
return htmlMode ? engine : HTML_ENGINE;
|
||||
}
|
||||
|
||||
private static final TemplateEngine HTML_ENGINE = engineFor(TemplateMode.HTML);
|
||||
|
||||
private static TemplateEngine engineFor(TemplateMode mode) {
|
||||
StringTemplateResolver resolver = new StringTemplateResolver();
|
||||
resolver.setTemplateMode(mode);
|
||||
resolver.setCacheable(false);
|
||||
TemplateEngine created = new TemplateEngine();
|
||||
created.setTemplateResolver(resolver);
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Builds the variable context, refusing an absent variable rather than rendering it away. */
|
||||
private Context contextFor(String source, Map<String, Object> variables) {
|
||||
Objects.requireNonNull(source, "source");
|
||||
Objects.requireNonNull(variables, "variables");
|
||||
requireEveryReferencedVariable(source, variables);
|
||||
Context context = new Context();
|
||||
variables.forEach(context::setVariable);
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Which keys a deployment is actually asked for.
|
||||
*
|
||||
* <p>Startup demanded all of them, always, so an SMTP-only platform with callbacks switched off had
|
||||
* to provision and rotate a Web Push signing key, a provider credential and two callback keys that
|
||||
* nothing in that configuration could reach. Keys that exist and are never used are the ones nobody
|
||||
* notices leaking, and requiring them made the four purposes every mode genuinely needs
|
||||
* indistinguishable from the four that follow a capability.
|
||||
*/
|
||||
class NotificationSecretRequirementsTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("the accept path's keys are required in every configuration")
|
||||
void theAcceptPathKeysAreAlwaysRequired() {
|
||||
var required = NotificationSecretRequirements.requiredBy(settings(false, Map.of()));
|
||||
|
||||
assertThat(required)
|
||||
.as(
|
||||
"contact points are protected, variables are encrypted at rest and provider request "
|
||||
+ "ids are hashed whenever the platform runs, providers or no providers")
|
||||
.containsExactlyInAnyOrder(
|
||||
SecretPurpose.CONTACT_ENCRYPTION,
|
||||
SecretPurpose.CONTACT_LOOKUP_HMAC,
|
||||
SecretPurpose.PAYLOAD_ENCRYPTION,
|
||||
SecretPurpose.PROVIDER_REQUEST_LOOKUP_HMAC);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an SMTP-only platform is not asked for a provider credential")
|
||||
void anSmtpOnlyPlatformIsNotAskedForAProviderCredential() {
|
||||
var required =
|
||||
NotificationSecretRequirements.requiredBy(settings(false, Map.of("mail", smtp())));
|
||||
|
||||
assertThat(required)
|
||||
.as("SMTP authenticates through spring.mail.*, so this platform holds no SMTP credential")
|
||||
.doesNotContain(SecretPurpose.PROVIDER_CREDENTIAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a disabled profile does not demand its family's keys")
|
||||
void aDisabledProfileDoesNotDemandItsKeys() {
|
||||
var required =
|
||||
NotificationSecretRequirements.requiredBy(
|
||||
settings(false, Map.of("push", disabled(webPush()))));
|
||||
|
||||
assertThat(required)
|
||||
.doesNotContain(SecretPurpose.VAPID_SIGNING)
|
||||
.doesNotContain(SecretPurpose.PROVIDER_CREDENTIAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an enabled Web Push profile demands a VAPID key and a provider credential")
|
||||
void anEnabledWebPushProfileDemandsItsKeys() {
|
||||
var required =
|
||||
NotificationSecretRequirements.requiredBy(settings(false, Map.of("push", webPush())));
|
||||
|
||||
assertThat(required).contains(SecretPurpose.VAPID_SIGNING, SecretPurpose.PROVIDER_CREDENTIAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("callbacks switched off do not demand the callback keys")
|
||||
void callbacksOffDoNotDemandTheCallbackKeys() {
|
||||
var required =
|
||||
NotificationSecretRequirements.requiredBy(settings(false, Map.of("mail", smtp())));
|
||||
|
||||
assertThat(required)
|
||||
.doesNotContain(SecretPurpose.CALLBACK_SIGNING)
|
||||
.doesNotContain(SecretPurpose.CALLBACK_FINGERPRINT_HMAC);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("callbacks switched on demand both callback keys")
|
||||
void callbacksOnDemandBothCallbackKeys() {
|
||||
var required =
|
||||
NotificationSecretRequirements.requiredBy(settings(true, Map.of("mail", smtp())));
|
||||
|
||||
assertThat(required)
|
||||
.as("verification and dedupe both run on the first callback that arrives")
|
||||
.contains(SecretPurpose.CALLBACK_SIGNING, SecretPurpose.CALLBACK_FINGERPRINT_HMAC);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a profile naming its own signing key ref demands the signing purpose")
|
||||
void aProfileNamingASigningRefDemandsTheSigningPurpose() {
|
||||
var required =
|
||||
NotificationSecretRequirements.requiredBy(settings(false, Map.of("sms", twilio())));
|
||||
|
||||
assertThat(required)
|
||||
.as("a profile that names a signing key intends to verify signatures with it")
|
||||
.contains(SecretPurpose.CALLBACK_SIGNING);
|
||||
}
|
||||
|
||||
private static NotificationPlatformSettings settings(
|
||||
boolean callbacksEnabled, Map<String, NotificationPlatformSettings.Provider> providers) {
|
||||
return new NotificationPlatformSettings(
|
||||
true,
|
||||
NotificationPlatformMode.SERVING,
|
||||
null,
|
||||
new NotificationPlatformSettings.Callbacks(callbacksEnabled, 1024, Duration.ofMinutes(5)),
|
||||
providers);
|
||||
}
|
||||
|
||||
private static NotificationPlatformSettings.Provider smtp() {
|
||||
return new NotificationPlatformSettings.Provider(
|
||||
"SMTP",
|
||||
true,
|
||||
true,
|
||||
"PRODUCTION",
|
||||
"smtp-main",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
Duration.ofSeconds(3),
|
||||
8,
|
||||
20);
|
||||
}
|
||||
|
||||
private static NotificationPlatformSettings.Provider webPush() {
|
||||
return new NotificationPlatformSettings.Provider(
|
||||
"WEB_PUSH",
|
||||
true,
|
||||
true,
|
||||
"PRODUCTION",
|
||||
"webpush-main",
|
||||
null,
|
||||
"BPublicKey",
|
||||
null,
|
||||
Duration.ofSeconds(3),
|
||||
8,
|
||||
20);
|
||||
}
|
||||
|
||||
private static NotificationPlatformSettings.Provider twilio() {
|
||||
return new NotificationPlatformSettings.Provider(
|
||||
"TWILIO",
|
||||
true,
|
||||
true,
|
||||
"PRODUCTION",
|
||||
"twilio-main",
|
||||
null,
|
||||
null,
|
||||
"twilio-callback-2026-08",
|
||||
Duration.ofSeconds(3),
|
||||
8,
|
||||
20);
|
||||
}
|
||||
|
||||
private static NotificationPlatformSettings.Provider disabled(
|
||||
NotificationPlatformSettings.Provider provider) {
|
||||
return new NotificationPlatformSettings.Provider(
|
||||
provider.type(),
|
||||
false,
|
||||
provider.primaryForChannel(),
|
||||
provider.environment(),
|
||||
provider.credentialProfile(),
|
||||
provider.topic(),
|
||||
provider.vapidPublicKey(),
|
||||
provider.callbackSigningSecretRef(),
|
||||
provider.timeout(),
|
||||
provider.maxConcurrency(),
|
||||
provider.ratePerSecond());
|
||||
}
|
||||
}
|
||||
+19
@@ -301,5 +301,24 @@ class LeaseRecoveryServiceTest {
|
||||
transitions.add(Map.entry(id, state));
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RecipientDeliveryRecord> saveHeldBy(
|
||||
RecipientDeliveryRecord record,
|
||||
dev.caskeleton.application.notification.platform.dispatch.RecipientLease lease) {
|
||||
return Optional.of(save(record));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RecipientDeliveryRecord> transitionHeldBy(
|
||||
RecipientDeliveryId id,
|
||||
RecipientDeliveryState state,
|
||||
Optional<Instant> nextDispatchAt,
|
||||
dev.caskeleton.application.notification.platform.dispatch.RecipientLease lease) {
|
||||
// This fake belongs to lease *recovery*, which runs for jobs whose holder is gone; the fenced
|
||||
// variants are the dispatch path's and are not exercised here.
|
||||
transition(id, state, nextDispatchAt);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.provider.http;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesProviderProperties;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.webhook.WebhookSubscription;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The endpoint guard is reached from the places that need it.
|
||||
*
|
||||
* <p>{@code EndpointRoutabilityTest} already proves {@code requireExternallyRoutable} rejects the
|
||||
* metadata service, RFC 1918, link-local and the rest. It proved that for months while the function
|
||||
* had no caller: both sites it was written for — a webhook target and an SES endpoint — kept
|
||||
* calling {@code requireSecureOrLoopback}, which reads the scheme and nothing else. A green test on
|
||||
* a control nothing invokes is the shape this repository keeps finding, and testing the helper
|
||||
* again would not have caught it.
|
||||
*
|
||||
* <p>So these assertions go through the constructors an operator and a caller actually reach.
|
||||
*
|
||||
* <p>The loopback allowance is part of that. It was a constant {@code true} at both call sites,
|
||||
* which left a client-supplied target naming {@code localhost} accepted — the residue this finding
|
||||
* carried until the allowance became {@code trusted}, the flag the record already used to decide
|
||||
* whether the same target may inherit a platform signing key.
|
||||
*/
|
||||
class EndpointGuardCallSiteTest {
|
||||
|
||||
private static final URI METADATA = URI.create("https://169.254.169.254/latest/meta-data/");
|
||||
private static final URI PRIVATE_NETWORK = URI.create("https://10.0.0.5/hook");
|
||||
|
||||
@Test
|
||||
@DisplayName("a webhook target on the cloud metadata service is refused")
|
||||
void aWebhookTargetOnTheMetadataServiceIsRefused() {
|
||||
assertThatThrownBy(() -> new WebhookSubscription("sub-1", METADATA, false, Optional.empty()))
|
||||
.as("a client-supplied target that fetches instance credentials is the SSRF this guards")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a webhook target inside the deployment's own network is refused")
|
||||
void aWebhookTargetOnAPrivateAddressIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() -> new WebhookSubscription("sub-1", PRIVATE_NETWORK, true, Optional.empty()))
|
||||
.as("trusted decides credential inheritance, not whether an internal address is reachable")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a webhook target carrying userinfo is refused")
|
||||
void aWebhookTargetWithUserinfoIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new WebhookSubscription(
|
||||
"sub-1",
|
||||
URI.create("https://evil.example.com@127.0.0.1/hook"),
|
||||
true,
|
||||
Optional.empty()))
|
||||
.as("the text before '@' is what a log reader takes for the host")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an SES endpoint inside the deployment's own network is refused")
|
||||
void anSesEndpointOnAPrivateAddressIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new SesProviderProperties(
|
||||
PRIVATE_NETWORK,
|
||||
"ap-northeast-2",
|
||||
"transactional@example.com",
|
||||
Optional.empty(),
|
||||
Duration.ofSeconds(3)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a client-supplied webhook target on the loopback interface is refused")
|
||||
void aDynamicWebhookTargetOnLoopbackIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new WebhookSubscription(
|
||||
"sub-1", URI.create("https://localhost/hook"), false, Optional.empty()))
|
||||
.as(
|
||||
"the loopback allowance was a constant `true`, so the one case the guard could not "
|
||||
+ "cover was a user-supplied target that simply named localhost")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a client-supplied webhook target on 127.0.0.1 is refused")
|
||||
void aDynamicWebhookTargetOnTheLoopbackAddressIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new WebhookSubscription(
|
||||
"sub-1", URI.create("http://127.0.0.1:8080/hook"), false, Optional.empty()))
|
||||
.as("naming the address rather than the host must not be the way around the refusal")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("loopback stays available, because local and contract profiles address it")
|
||||
void loopbackIsStillAccepted() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
new WebhookSubscription(
|
||||
"sub-1", URI.create("http://127.0.0.1:8080/hook"), true, Optional.empty()))
|
||||
.as(
|
||||
"this is the case the allowance exists for, and it is the reason the refusal above "
|
||||
+ "has to be conditional rather than absolute")
|
||||
.doesNotThrowAnyException();
|
||||
assertThatCode(
|
||||
() ->
|
||||
new SesProviderProperties(
|
||||
URI.create("http://localhost:4566"),
|
||||
"ap-northeast-2",
|
||||
"transactional@example.com",
|
||||
Optional.empty(),
|
||||
Duration.ofSeconds(3)))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
+263
-15
@@ -1,25 +1,49 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.UnconfiguredAttachmentResolver;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderAdapterContract;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures;
|
||||
import dev.caskeleton.application.notification.platform.api.content.AttachmentDisposition;
|
||||
import dev.caskeleton.application.notification.platform.api.content.AttachmentRef;
|
||||
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
|
||||
import dev.caskeleton.application.notification.platform.api.content.EmailOptions;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel;
|
||||
import dev.caskeleton.application.notification.platform.api.error.AttachmentIntegrityException;
|
||||
import dev.caskeleton.application.notification.platform.api.error.AttachmentUnavailableException;
|
||||
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
|
||||
import dev.caskeleton.application.notification.platform.provider.AttachmentResolver;
|
||||
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
|
||||
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
|
||||
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
|
||||
import jakarta.mail.Session;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -39,21 +63,8 @@ class SesNotificationProviderAdapterTest extends ProviderAdapterContract {
|
||||
|
||||
@Override
|
||||
protected NotificationProviderAdapter adapter() {
|
||||
var properties =
|
||||
new SesProviderProperties(
|
||||
harness.baseUri(),
|
||||
"ap-northeast-2",
|
||||
"transactional@example.com",
|
||||
Optional.empty(),
|
||||
Duration.ofSeconds(3));
|
||||
return new SesNotificationProviderAdapter(
|
||||
new JdkNotificationHttpGateway(Duration.ofSeconds(2)),
|
||||
new SesRequestMapper(properties, new AwsSignatureV4Signer()),
|
||||
new SesFailureClassifier(),
|
||||
protector,
|
||||
SecurityFixtures.keys(),
|
||||
"AKIAEXAMPLE",
|
||||
CLOCK);
|
||||
return adapter(
|
||||
SecurityFixtures.credentials("ses-primary"), new UnconfiguredAttachmentResolver());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -101,4 +112,241 @@ class SesNotificationProviderAdapterTest extends ProviderAdapterContract {
|
||||
assertThat(recorded.header("X-Amz-Content-Sha256")).isPresent();
|
||||
assertThat(recorded.uri().toString()).doesNotContain(ProviderFixtures.SECRET_EMAIL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentWithNoAttachmentStaysOnTheSimpleShape() {
|
||||
harness.respondWith(200, successBody(), Map.of());
|
||||
|
||||
adapter().submit(submission()).toCompletableFuture().join();
|
||||
|
||||
var content = requestContent(0);
|
||||
assertThat(content.get("Simple")).isNotNull();
|
||||
assertThat(content.get("Raw")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDeclaredAttachmentIsCarriedAsRawMimeContent() {
|
||||
byte[] bytes = documentBytes();
|
||||
harness.respondWith(200, successBody(), Map.of());
|
||||
|
||||
var result =
|
||||
adapter(SecurityFixtures.credentials("ses-primary"), resolverReturning(bytes))
|
||||
.submit(submissionWithAttachment(bytes))
|
||||
.toCompletableFuture()
|
||||
.join();
|
||||
|
||||
assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
|
||||
var content = requestContent(0);
|
||||
assertThat(content.get("Simple"))
|
||||
.as("Simple content has no MIME part, so the declared attachment was simply not sent")
|
||||
.isNull();
|
||||
String mime =
|
||||
new String(
|
||||
Base64.getDecoder().decode(content.get("Raw").get("Data").asString()),
|
||||
StandardCharsets.UTF_8);
|
||||
assertThat(mime).contains("Contract subject");
|
||||
assertThat(mime).contains("invoice.pdf");
|
||||
assertThat(mime)
|
||||
.as("the document itself, base64 encoded as a binary part rather than merely named")
|
||||
.contains(Base64.getEncoder().encodeToString(bytes));
|
||||
assertThat(harness.received().get(0).header("Authorization").orElseThrow())
|
||||
.startsWith("AWS4-HMAC-SHA256");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAttachmentThatCannotBeResolvedIsRefusedBeforeAnythingIsSent() {
|
||||
byte[] bytes = documentBytes();
|
||||
harness.respondWith(200, successBody(), Map.of());
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> adapter().submit(submissionWithAttachment(bytes)).toCompletableFuture().join())
|
||||
.as("a mail that silently loses its attachment is worse than one that is not sent")
|
||||
.isInstanceOf(AttachmentUnavailableException.class);
|
||||
|
||||
assertThat(harness.received()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void attachmentBytesThatAreNotTheApprovedBytesAreRefusedBeforeAnythingIsSent() {
|
||||
byte[] approved = "invoice-bytes".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] substituted = "1nvo1ce-bytes".getBytes(StandardCharsets.UTF_8);
|
||||
harness.respondWith(200, successBody(), Map.of());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
adapter(SecurityFixtures.credentials("ses-primary"), resolverReturning(substituted))
|
||||
.submit(submissionWithAttachment(approved))
|
||||
.toCompletableFuture()
|
||||
.join())
|
||||
.as("same size, different bytes: only the digest separates them")
|
||||
.isInstanceOf(AttachmentIntegrityException.class);
|
||||
|
||||
assertThat(harness.received()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMessageLargerThanSesAcceptsIsRefusedBeforeItIsSigned() {
|
||||
// Base64 transfer encoding adds a third, so this clears the limit as bytes and exceeds it as a
|
||||
// message — which is exactly the case a check against the declared attachment size misses.
|
||||
byte[] oversized = new byte[8 * 1_000_000];
|
||||
assertThat(oversized.length).isLessThan((int) SesRequestMapper.MAX_MESSAGE_BYTES);
|
||||
harness.respondWith(200, successBody(), Map.of());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
adapter(SecurityFixtures.credentials("ses-primary"), resolverReturning(oversized))
|
||||
.submit(submissionWithAttachment(oversized))
|
||||
.toCompletableFuture()
|
||||
.join())
|
||||
.isInstanceOf(ProviderPayloadLimitException.class);
|
||||
|
||||
assertThat(harness.received()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoProfilesAreSignedWithTheirOwnCredential() {
|
||||
// One submission, sent twice: the SES request body is derived from content and recipient alone,
|
||||
// so anything that differs between the two signatures is the credential.
|
||||
var submission = submission();
|
||||
harness.respondWith(200, successBody(), Map.of());
|
||||
adapter(
|
||||
SecurityFixtures.credentials("ses-primary", "cred-1"),
|
||||
new UnconfiguredAttachmentResolver())
|
||||
.submit(submission)
|
||||
.toCompletableFuture()
|
||||
.join();
|
||||
harness.respondWith(200, successBody(), Map.of());
|
||||
adapter(
|
||||
SecurityFixtures.credentials("ses-primary", "cred-2"),
|
||||
new UnconfiguredAttachmentResolver())
|
||||
.submit(submission)
|
||||
.toCompletableFuture()
|
||||
.join();
|
||||
|
||||
assertThat(harness.received().get(1).header("Authorization"))
|
||||
.as(
|
||||
"every profile signed with the platform's one current provider credential, so a leak "
|
||||
+ "of one SES account's key was a leak of every provider account")
|
||||
.isNotEqualTo(harness.received().get(0).header("Authorization"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aProfileWithNoActivatedCredentialIsRefusedBeforeTheRequest() {
|
||||
harness.respondWith(200, successBody(), Map.of());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
adapter(
|
||||
SecurityFixtures.credentials("some-other-profile"),
|
||||
new UnconfiguredAttachmentResolver())
|
||||
.submit(submission())
|
||||
.toCompletableFuture()
|
||||
.join())
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
|
||||
assertThat(harness.received()).isEmpty();
|
||||
}
|
||||
|
||||
private SesNotificationProviderAdapter adapter(
|
||||
ProviderCredentialManager credentials, AttachmentResolver attachments) {
|
||||
var properties =
|
||||
new SesProviderProperties(
|
||||
harness.baseUri(),
|
||||
"ap-northeast-2",
|
||||
"transactional@example.com",
|
||||
Optional.empty(),
|
||||
Duration.ofSeconds(3));
|
||||
return new SesNotificationProviderAdapter(
|
||||
new JdkNotificationHttpGateway(Duration.ofSeconds(2)),
|
||||
new SesRequestMapper(
|
||||
properties,
|
||||
new AwsSignatureV4Signer(),
|
||||
new SmtpMimeMessageFactory(Session.getInstance(new Properties()))),
|
||||
new AttachmentIntegrityGuard(attachments),
|
||||
new SesFailureClassifier(),
|
||||
protector,
|
||||
credentials,
|
||||
"AKIAEXAMPLE",
|
||||
CLOCK);
|
||||
}
|
||||
|
||||
/** The {@code Content} object of a recorded request. */
|
||||
private tools.jackson.databind.JsonNode requestContent(int index) {
|
||||
return NotificationJsonMapper.mapper()
|
||||
.readTree(harness.received().get(index).bodyAsString())
|
||||
.get("Content");
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolver that hands back exactly these bytes and describes them honestly.
|
||||
*
|
||||
* <p>The digest is computed from what is returned rather than copied from the reference, so a
|
||||
* resolver that returns something other than the approved bytes is caught by the integrity guard
|
||||
* instead of being waved through by a fixture that agrees with itself.
|
||||
*/
|
||||
private static AttachmentResolver resolverReturning(byte[] bytes) {
|
||||
return (reference, context) ->
|
||||
new ResolvedAttachment(
|
||||
new ByteArrayInputStream(bytes),
|
||||
bytes.length,
|
||||
digestOf(bytes),
|
||||
reference.contentType(),
|
||||
reference.displayName());
|
||||
}
|
||||
|
||||
private ProviderSubmission submissionWithAttachment(byte[] approvedBytes) {
|
||||
return ProviderFixtures.submission(
|
||||
ProviderFixtures.profile("ses-primary", "ses", Channel.EMAIL),
|
||||
Channel.EMAIL,
|
||||
new EmailContent(
|
||||
"Contract subject",
|
||||
"Contract body",
|
||||
Optional.empty(),
|
||||
List.of(
|
||||
new AttachmentRef(
|
||||
"storage://bucket/invoice.pdf",
|
||||
"invoice.pdf",
|
||||
"application/pdf",
|
||||
approvedBytes.length,
|
||||
digestOf(approvedBytes),
|
||||
AttachmentDisposition.ATTACHMENT)),
|
||||
EmailOptions.DEFAULT),
|
||||
protector,
|
||||
EmailAddress.parse(ProviderFixtures.SECRET_EMAIL),
|
||||
Optional.of(CLOCK.instant().plus(Duration.ofHours(1))));
|
||||
}
|
||||
|
||||
/**
|
||||
* A short binary document.
|
||||
*
|
||||
* <p>Binary rather than text on purpose: MIME encodes an ASCII part as {@code 7bit} and leaves it
|
||||
* legible, which would let the assertion pass on a part that was never really encoded at all.
|
||||
*/
|
||||
private static byte[] documentBytes() {
|
||||
return new byte[] {
|
||||
'%',
|
||||
'P',
|
||||
'D',
|
||||
'F',
|
||||
'-',
|
||||
'1',
|
||||
'.',
|
||||
'7',
|
||||
'\n',
|
||||
(byte) 0x80,
|
||||
(byte) 0xC3,
|
||||
0x00,
|
||||
0x01,
|
||||
0x02,
|
||||
(byte) 0xFF
|
||||
};
|
||||
}
|
||||
|
||||
private static String digestOf(byte[] bytes) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
} catch (NoSuchAlgorithmException unavailable) {
|
||||
throw new IllegalStateException("SHA-256 is required by every supported JRE", unavailable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
|
||||
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
|
||||
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* That an attached document arrives with its bytes in it.
|
||||
*
|
||||
* <p>The factory handed JavaMail the resolver's stream directly. JavaMail reads an attachment twice
|
||||
* — once to choose the part's transfer encoding, once to write the part — and the second read of an
|
||||
* already drained stream returns nothing, so the message went out announcing a filename and
|
||||
* carrying no content, and the attempt was recorded as accepted. Nothing noticed because every test
|
||||
* asserted on the outcome of the send rather than on what was sent.
|
||||
*
|
||||
* <p>Asserted on the serialised message, because that is the only place the defect was visible: the
|
||||
* part existed, its headers were right, and its body was empty.
|
||||
*/
|
||||
class SmtpAttachmentBodyTest {
|
||||
|
||||
private static final byte[] DOCUMENT = "invoice-body-bytes".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private final ContactPointProtector protector =
|
||||
new AesGcmContactPointProtector(SecurityFixtures.keys());
|
||||
|
||||
private final SmtpMimeMessageFactory factory =
|
||||
new SmtpMimeMessageFactory(Session.getInstance(new Properties()));
|
||||
|
||||
@Test
|
||||
@DisplayName("an attached document is written into the message, not just named by it")
|
||||
void anAttachedDocumentCarriesItsBytes() throws Exception {
|
||||
MimeMessage message =
|
||||
factory.create(
|
||||
submission(),
|
||||
"recipient@example.test",
|
||||
"sender@example.test",
|
||||
List.of(attachment(DOCUMENT, DOCUMENT.length)));
|
||||
|
||||
assertThat(attachmentBytesOf(message))
|
||||
.as("the part announced a filename and carried nothing")
|
||||
.isEqualTo(DOCUMENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("content that is not the size the guard approved is refused")
|
||||
void contentThatIsNotTheApprovedSizeIsRefused() {
|
||||
// The integrity guard pins a size against the reference before the stream is handed over, so a
|
||||
// stream that turns out to be a different length is not the document that was approved —
|
||||
// whatever digest travelled with it.
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
factory.create(
|
||||
submission(),
|
||||
"recipient@example.test",
|
||||
"sender@example.test",
|
||||
List.of(attachment(DOCUMENT, DOCUMENT.length + 1))))
|
||||
.isInstanceOf(RuntimeException.class);
|
||||
}
|
||||
|
||||
private static ResolvedAttachment attachment(byte[] content, long declaredSize) {
|
||||
return new ResolvedAttachment(
|
||||
new ByteArrayInputStream(content),
|
||||
declaredSize,
|
||||
"sha-256:not-checked-here",
|
||||
"application/pdf",
|
||||
"invoice.pdf");
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes of the attachment part, read back the way a receiving client reads them.
|
||||
*
|
||||
* <p>Read from the serialised message rather than from the part object, because the defect was
|
||||
* exactly that the object described a part the serialisation could not fill: assertions taken
|
||||
* before {@code writeTo} saw an attachment that was about to be written empty.
|
||||
*/
|
||||
private static byte[] attachmentBytesOf(MimeMessage message) throws Exception {
|
||||
ByteArrayOutputStream wire = new ByteArrayOutputStream();
|
||||
message.writeTo(wire);
|
||||
MimeMessage received =
|
||||
new MimeMessage(
|
||||
Session.getInstance(new Properties()),
|
||||
new java.io.ByteArrayInputStream(wire.toByteArray()));
|
||||
jakarta.mail.internet.MimeMultipart parts =
|
||||
(jakarta.mail.internet.MimeMultipart) received.getContent();
|
||||
for (int index = 0; index < parts.getCount(); index++) {
|
||||
jakarta.mail.BodyPart part = parts.getBodyPart(index);
|
||||
if ("invoice.pdf".equals(part.getFileName())) {
|
||||
return part.getInputStream().readAllBytes();
|
||||
}
|
||||
}
|
||||
throw new AssertionError("the message carries no attachment part at all");
|
||||
}
|
||||
|
||||
private ProviderSubmission submission() {
|
||||
return ProviderFixtures.submission(
|
||||
ProviderFixtures.profile("smtp-primary", "smtp", Channel.EMAIL),
|
||||
Channel.EMAIL,
|
||||
ProviderFixtures.email(),
|
||||
protector,
|
||||
EmailAddress.parse(ProviderFixtures.SECRET_EMAIL),
|
||||
Optional.empty());
|
||||
}
|
||||
}
|
||||
+42
@@ -29,6 +29,7 @@ class TwilioCallbackAndProjectionTest {
|
||||
Optional.of("MG123"),
|
||||
Optional.empty(),
|
||||
CALLBACK_URL,
|
||||
"cb-1",
|
||||
java.time.Duration.ofSeconds(3),
|
||||
java.time.Duration.ofHours(12));
|
||||
|
||||
@@ -54,6 +55,47 @@ class TwilioCallbackAndProjectionTest {
|
||||
assertThat(events.get(0).providerRequestId()).contains("SM1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoProfilesVerifyWithTheirOwnSigningKey() {
|
||||
Map<String, String> parameters =
|
||||
new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered"));
|
||||
var request = callback(parameters, signature(parameters));
|
||||
|
||||
// Signed with cb-1, presented to a profile whose reference names cb-2. Verification used the
|
||||
// platform's one current callback signing key, so every Twilio profile shared one secret and a
|
||||
// subaccount whose token leaked could forge status callbacks for any other.
|
||||
assertThat(adapterWithSigningRef("cb-2").verify(request).valid()).isFalse();
|
||||
assertThat(adapterWithSigningRef("cb-1").verify(request).valid()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSigningRefNamingAKeyOfAnotherPurposeIsRefusedBeforeAVerdict() {
|
||||
Map<String, String> parameters =
|
||||
new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered"));
|
||||
var request = callback(parameters, signature(parameters));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(
|
||||
() -> adapterWithSigningRef("enc-1").verify(request))
|
||||
.as("a misfiled reference is a configuration fault, not a signature that never matches")
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
private TwilioCallbackAdapter adapterWithSigningRef(String signingKeyRef) {
|
||||
return new TwilioCallbackAdapter(
|
||||
new TwilioSignatureValidator(),
|
||||
new TwilioStatusNormalizer(),
|
||||
new TwilioProviderProperties(
|
||||
java.net.URI.create("https://api.twilio.example"),
|
||||
"AC123",
|
||||
Optional.of("MG123"),
|
||||
Optional.empty(),
|
||||
CALLBACK_URL,
|
||||
signingKeyRef,
|
||||
java.time.Duration.ofSeconds(3),
|
||||
java.time.Duration.ofHours(12)),
|
||||
SecurityFixtures.keys());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidSignatureIsRejected() {
|
||||
Map<String, String> parameters =
|
||||
|
||||
+1
@@ -29,6 +29,7 @@ class TwilioCallbackContractTest extends CallbackContract {
|
||||
Optional.of("MG123"),
|
||||
Optional.empty(),
|
||||
CALLBACK_URL,
|
||||
"cb-1",
|
||||
Duration.ofSeconds(3),
|
||||
Duration.ofHours(12));
|
||||
|
||||
|
||||
+2
-1
@@ -39,6 +39,7 @@ class TwilioSmsProviderAdapterTest extends ProviderAdapterContract {
|
||||
Optional.of("MG123"),
|
||||
Optional.empty(),
|
||||
"https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary",
|
||||
"cb-1",
|
||||
Duration.ofSeconds(3),
|
||||
Duration.ofHours(12));
|
||||
}
|
||||
@@ -50,7 +51,7 @@ class TwilioSmsProviderAdapterTest extends ProviderAdapterContract {
|
||||
new TwilioRequestMapper(properties()),
|
||||
new TwilioFailureClassifier(),
|
||||
protector,
|
||||
SecurityFixtures.keys());
|
||||
SecurityFixtures.credentials("twilio-primary"));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+192
-13
@@ -1,23 +1,37 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.provider.webhook;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.SettingsSecretMaterialProvider;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures;
|
||||
import dev.caskeleton.application.notification.platform.api.content.InAppContent;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation;
|
||||
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.contact.InAppRecipientRef;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
|
||||
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.net.URI;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -37,14 +51,52 @@ class WebhookNotificationProviderAdapterTest {
|
||||
|
||||
@Test
|
||||
void dynamicTargetNeverInheritsTrustedCredentials() {
|
||||
harness.respondWith(200, "{}", Map.of());
|
||||
// Both gateway arguments used to be the same instance, so "the request carried no
|
||||
// Authorization header" was a property of the test's own wiring rather than of the adapter:
|
||||
// there was nothing in the graph that could have added one, and the assertion would have held
|
||||
// just as well for a trusted subscription. What actually decides credential inheritance is
|
||||
// which of the two gateways the adapter hands the request to, and that is only observable when
|
||||
// they are distinguishable.
|
||||
//
|
||||
// The target is a routable literal rather than the local harness because a client-supplied
|
||||
// target may no longer name the loopback interface (NTF-012). A literal address also keeps the
|
||||
// constructor's resolution check off DNS.
|
||||
RecordingGateway trusted = new RecordingGateway();
|
||||
RecordingGateway dynamic = new RecordingGateway();
|
||||
WebhookSubscription subscription =
|
||||
new WebhookSubscription("sub-2", ROUTABLE_TARGET, false, Optional.empty());
|
||||
|
||||
adapter(dynamicSubscription()).submit(submission()).toCompletableFuture().join();
|
||||
adapter(trusted, dynamic, submission -> subscription)
|
||||
.submit(submission())
|
||||
.toCompletableFuture()
|
||||
.join();
|
||||
|
||||
var recorded = harness.received().get(0);
|
||||
assertThat(recorded.header("Authorization")).isEmpty();
|
||||
assertThat(recorded.header("Cookie")).isEmpty();
|
||||
assertThat(recorded.header(WebhookSignatureStrategy.SIGNATURE_HEADER)).isEmpty();
|
||||
assertThat(trusted.exchanged)
|
||||
.as("a client-supplied target must not reach the gateway that carries platform credentials")
|
||||
.isEmpty();
|
||||
assertThat(dynamic.exchanged).hasSize(1);
|
||||
assertThat(dynamic.exchanged.get(0).headers())
|
||||
.doesNotContainKeys("authorization", "cookie", WebhookSignatureStrategy.SIGNATURE_HEADER);
|
||||
}
|
||||
|
||||
@Test
|
||||
void trustedTargetReachesTheCredentialedGateway() {
|
||||
// The counterpart, so the assertion above cannot pass by the adapter never reaching either
|
||||
// gateway, and so the routing rule is watched working in both directions.
|
||||
RecordingGateway trusted = new RecordingGateway();
|
||||
RecordingGateway dynamic = new RecordingGateway();
|
||||
WebhookSubscription subscription =
|
||||
new WebhookSubscription("sub-1", ROUTABLE_TARGET, true, Optional.of("cb-1"));
|
||||
|
||||
adapter(trusted, dynamic, submission -> subscription)
|
||||
.submit(submission())
|
||||
.toCompletableFuture()
|
||||
.join();
|
||||
|
||||
assertThat(dynamic.exchanged).isEmpty();
|
||||
assertThat(trusted.exchanged).hasSize(1);
|
||||
assertThat(trusted.exchanged.get(0).headers())
|
||||
.containsKey(WebhookSignatureStrategy.SIGNATURE_HEADER);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -78,33 +130,160 @@ class WebhookNotificationProviderAdapterTest {
|
||||
assertThat(result.failure().orElseThrow().nativeCode().orElseThrow().length()).isLessThan(1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoSubscriptionsWithDifferentKeyRefsAreSignedDifferently() {
|
||||
// One submission for both deliveries: the attempt id is part of the body, so two submissions
|
||||
// would differ in what was signed and the signatures would differ whatever key was used.
|
||||
var submission = submission();
|
||||
harness.respondWith(200, "{}", Map.of());
|
||||
adapter(trustedSubscription("cb-1")).submit(submission).toCompletableFuture().join();
|
||||
harness.respondWith(200, "{}", Map.of());
|
||||
adapter(trustedSubscription("cb-2")).submit(submission).toCompletableFuture().join();
|
||||
|
||||
var first = harness.received().get(0).header(WebhookSignatureStrategy.SIGNATURE_HEADER);
|
||||
var second = harness.received().get(1).header(WebhookSignatureStrategy.SIGNATURE_HEADER);
|
||||
|
||||
assertThat(first).isPresent();
|
||||
assertThat(second)
|
||||
.as(
|
||||
"signingKeyRef only decided whether to sign; the signature came from one platform key, "
|
||||
+ "so every trusted receiver could verify and forge every other receiver's webhook")
|
||||
.isPresent()
|
||||
.isNotEqualTo(first);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aKeyRefThatIsNotACallbackSigningKeyIsRefusedBeforeTheRequest() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
adapter(trustedSubscription("cred-1"))
|
||||
.submit(submission())
|
||||
.toCompletableFuture()
|
||||
.join())
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
|
||||
assertThat(harness.received()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBodyOverTheDeclaredCeilingIsRefusedBeforeTheRequest() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
adapter(trustedSubscription("cb-1"))
|
||||
.submit(oversizedSubmission())
|
||||
.toCompletableFuture()
|
||||
.join())
|
||||
.isInstanceOf(ProviderPayloadLimitException.class);
|
||||
|
||||
assertThat(harness.received())
|
||||
.as("the capability declared a ceiling and nothing measured the bytes against it")
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* A target outside every range the endpoint guard refuses, written as a literal.
|
||||
*
|
||||
* <p>TEST-NET-3, which is reserved for documentation and routes nowhere — and being a literal, it
|
||||
* is never looked up, so the guard's resolution step does not make these tests depend on DNS.
|
||||
*/
|
||||
private static final URI ROUTABLE_TARGET = URI.create("https://203.0.113.10/hook");
|
||||
|
||||
/** Records what it was asked to send and answers 200, so nothing is dialled. */
|
||||
private static final class RecordingGateway implements NotificationHttpGateway {
|
||||
|
||||
private final List<NotificationHttpRequest> exchanged = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public NotificationHttpResponse exchange(NotificationHttpRequest request) {
|
||||
exchanged.add(request);
|
||||
return new NotificationHttpResponse(
|
||||
200, Map.of(), "{}".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private WebhookNotificationProviderAdapter adapter(
|
||||
NotificationHttpGateway trustedGateway,
|
||||
NotificationHttpGateway dynamicGateway,
|
||||
Function<ProviderSubmission, WebhookSubscription> subscriptions) {
|
||||
return new WebhookNotificationProviderAdapter(
|
||||
trustedGateway,
|
||||
dynamicGateway,
|
||||
new WebhookSignatureStrategy(),
|
||||
keys(),
|
||||
subscriptions,
|
||||
Duration.ofSeconds(3),
|
||||
CLOCK);
|
||||
}
|
||||
|
||||
private WebhookNotificationProviderAdapter adapter(WebhookSubscription subscription) {
|
||||
var gateway = new JdkNotificationHttpGateway(Duration.ofSeconds(2));
|
||||
return new WebhookNotificationProviderAdapter(
|
||||
gateway,
|
||||
gateway,
|
||||
new WebhookSignatureStrategy(),
|
||||
SecurityFixtures.keys(),
|
||||
keys(),
|
||||
submission -> subscription,
|
||||
Duration.ofSeconds(3),
|
||||
CLOCK);
|
||||
}
|
||||
|
||||
private WebhookSubscription trustedSubscription() {
|
||||
return new WebhookSubscription(
|
||||
"sub-1", harness.baseUri().resolve("/hook"), true, Optional.of("callback-sign"));
|
||||
/**
|
||||
* Two callback signing keys, so a per-subscription reference has something to distinguish.
|
||||
*
|
||||
* <p>{@code cred-1} is present as well: a reference naming a key issued for another purpose is a
|
||||
* configuration fault this adapter has to catch rather than sign with.
|
||||
*/
|
||||
private static SecretMaterialProvider keys() {
|
||||
return new SettingsSecretMaterialProvider(
|
||||
Map.of(
|
||||
SecretPurpose.CALLBACK_SIGNING,
|
||||
new SecretKeyMaterial("cb-1", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x33)),
|
||||
SecretPurpose.CONTACT_ENCRYPTION,
|
||||
new SecretKeyMaterial("enc-1", SecretPurpose.CONTACT_ENCRYPTION, filled((byte) 0x11)),
|
||||
SecretPurpose.CONTACT_LOOKUP_HMAC,
|
||||
new SecretKeyMaterial("mac-1", SecretPurpose.CONTACT_LOOKUP_HMAC, filled((byte) 0x22))),
|
||||
Map.of(
|
||||
"cb-2",
|
||||
new SecretKeyMaterial("cb-2", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x34)),
|
||||
"cred-1",
|
||||
new SecretKeyMaterial(
|
||||
"cred-1", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x44))));
|
||||
}
|
||||
|
||||
private WebhookSubscription dynamicSubscription() {
|
||||
private static byte[] filled(byte value) {
|
||||
byte[] material = new byte[32];
|
||||
java.util.Arrays.fill(material, value);
|
||||
return material;
|
||||
}
|
||||
|
||||
private WebhookSubscription trustedSubscription() {
|
||||
return trustedSubscription("cb-1");
|
||||
}
|
||||
|
||||
private WebhookSubscription trustedSubscription(String signingKeyRef) {
|
||||
return new WebhookSubscription(
|
||||
"sub-2", harness.baseUri().resolve("/hook"), false, Optional.empty());
|
||||
"sub-1", harness.baseUri().resolve("/hook"), true, Optional.of(signingKeyRef));
|
||||
}
|
||||
|
||||
private ProviderSubmission submission() {
|
||||
return submission(ProviderFixtures.webhook());
|
||||
}
|
||||
|
||||
private ProviderSubmission oversizedSubmission() {
|
||||
return submission(
|
||||
new InAppContent(
|
||||
"Order shipped",
|
||||
"x".repeat((int) WebhookNotificationProviderAdapter.MAX_BODY_BYTES + 1),
|
||||
Optional.empty(),
|
||||
java.util.List.of(),
|
||||
"order"));
|
||||
}
|
||||
|
||||
private ProviderSubmission submission(InAppContent content) {
|
||||
return ProviderFixtures.submission(
|
||||
ProviderFixtures.profile("webhook-main", "webhook", Channel.WEBHOOK),
|
||||
Channel.WEBHOOK,
|
||||
ProviderFixtures.webhook(),
|
||||
content,
|
||||
protector,
|
||||
new InAppRecipientRef("user-1"),
|
||||
Optional.empty());
|
||||
|
||||
+9
-4
@@ -49,7 +49,7 @@ class CallbackPayloadBoundTest {
|
||||
() ->
|
||||
new AesGcmCallbackPayloadProtection(
|
||||
SecurityFixtures.keys(),
|
||||
new java.security.SecureRandom(),
|
||||
payloads(),
|
||||
AesGcmCallbackPayloadProtection.MAX_CIPHERTEXT_BYTES))
|
||||
.as("this is exactly the configuration that produced 65,564 bytes of ciphertext")
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
@@ -62,7 +62,7 @@ class CallbackPayloadBoundTest {
|
||||
AesGcmCallbackPayloadProtection protection =
|
||||
new AesGcmCallbackPayloadProtection(
|
||||
SecurityFixtures.keys(),
|
||||
new java.security.SecureRandom(),
|
||||
payloads(),
|
||||
AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES);
|
||||
|
||||
byte[] stored =
|
||||
@@ -78,7 +78,7 @@ class CallbackPayloadBoundTest {
|
||||
AesGcmCallbackPayloadProtection protection =
|
||||
new AesGcmCallbackPayloadProtection(
|
||||
SecurityFixtures.keys(),
|
||||
new java.security.SecureRandom(),
|
||||
payloads(),
|
||||
AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES);
|
||||
|
||||
byte[] stored =
|
||||
@@ -96,8 +96,13 @@ class CallbackPayloadBoundTest {
|
||||
() ->
|
||||
new AesGcmCallbackPayloadProtection(
|
||||
SecurityFixtures.keys(),
|
||||
new java.security.SecureRandom(),
|
||||
payloads(),
|
||||
AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
private static AesGcmNotificationPayloadProtection payloads() {
|
||||
return new AesGcmNotificationPayloadProtection(
|
||||
SecurityFixtures.keys(), new java.security.SecureRandom());
|
||||
}
|
||||
}
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* What a retained callback is worth after the payload key rotates.
|
||||
*
|
||||
* <p>The retained raw body exists for one reason: a normalization bug is only diagnosable against
|
||||
* what the provider actually sent. The stored bytes were a nonce and a ciphertext and nothing else,
|
||||
* so the first rotation of the payload encryption key turned every retained callback into bytes
|
||||
* that no key could be matched to — the retention outlived the key but not the ability to name it,
|
||||
* which is the same as not retaining it.
|
||||
*/
|
||||
class CallbackPayloadRotationTest {
|
||||
|
||||
private static final byte[] RAW =
|
||||
"{\"MessageId\":\"m-1\",\"eventType\":\"Delivery\"}".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
@Test
|
||||
@DisplayName("a callback retained before a rotation is still readable after it")
|
||||
void aCallbackRetainedBeforeARotationIsStillReadableAfterIt() {
|
||||
byte[] stored = protection(beforeRotation()).protectRawPayload(RAW);
|
||||
|
||||
byte[] revealed =
|
||||
new AesGcmNotificationPayloadProtection(afterRotation(), new SecureRandom()).reveal(stored);
|
||||
|
||||
assertThat(revealed)
|
||||
.as("the envelope names the key it used, so the retired key can be asked for by id")
|
||||
.isEqualTo(RAW);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the retained bytes name the key that encrypted them")
|
||||
void theRetainedBytesNameTheKeyThatEncryptedThem() {
|
||||
byte[] stored = protection(beforeRotation()).protectRawPayload(RAW);
|
||||
|
||||
int keyIdLength = Byte.toUnsignedInt(stored[1]);
|
||||
String keyId = new String(stored, 2, keyIdLength, StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(stored[0])
|
||||
.as("a format that cannot say which format it is can only change by rewriting every row")
|
||||
.isEqualTo(AesGcmNotificationPayloadProtection.VERSION);
|
||||
assertThat(keyId).isEqualTo("payload-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the retained bytes are not the payload")
|
||||
void theRetainedBytesAreNotThePayload() {
|
||||
byte[] stored = protection(beforeRotation()).protectRawPayload(RAW);
|
||||
|
||||
assertThat(new String(stored, StandardCharsets.UTF_8)).doesNotContain("MessageId");
|
||||
}
|
||||
|
||||
private static AesGcmCallbackPayloadProtection protection(SecretMaterialProvider keys) {
|
||||
return new AesGcmCallbackPayloadProtection(
|
||||
keys,
|
||||
new AesGcmNotificationPayloadProtection(keys, new SecureRandom()),
|
||||
AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES);
|
||||
}
|
||||
|
||||
/** The key store as it stood when the callback arrived. */
|
||||
private static SecretMaterialProvider beforeRotation() {
|
||||
return new SettingsSecretMaterialProvider(
|
||||
Map.of(
|
||||
SecretPurpose.PAYLOAD_ENCRYPTION,
|
||||
payloadKey("payload-1", (byte) 0x55),
|
||||
SecretPurpose.CALLBACK_FINGERPRINT_HMAC,
|
||||
new SecretKeyMaterial(
|
||||
"fp-1", SecretPurpose.CALLBACK_FINGERPRINT_HMAC, filled((byte) 0x88))),
|
||||
Map.of());
|
||||
}
|
||||
|
||||
/** The key store after the payload key was replaced and the old one retired. */
|
||||
private static SecretMaterialProvider afterRotation() {
|
||||
return new SettingsSecretMaterialProvider(
|
||||
Map.of(SecretPurpose.PAYLOAD_ENCRYPTION, payloadKey("payload-2", (byte) 0x56)),
|
||||
Map.of("payload-1", payloadKey("payload-1", (byte) 0x55)));
|
||||
}
|
||||
|
||||
private static SecretKeyMaterial payloadKey(String keyId, byte fill) {
|
||||
return new SecretKeyMaterial(keyId, SecretPurpose.PAYLOAD_ENCRYPTION, filled(fill));
|
||||
}
|
||||
|
||||
private static byte[] filled(byte value) {
|
||||
byte[] material = new byte[32];
|
||||
Arrays.fill(material, value);
|
||||
return material;
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* A rotation as a window rather than an instant.
|
||||
*
|
||||
* <p>A superseded generation used to be overwritten outright. The attempts planned against it are
|
||||
* already in flight when the rotation lands, and they are the ones that cannot simply be failed and
|
||||
* cannot be replayed either — the provider may already have acted on the request. Retiring the
|
||||
* credential at the moment the new one arrives fails exactly that work.
|
||||
*
|
||||
* <p>The opposite mistake is keeping it forever, which is not a rotation but two live credentials.
|
||||
* The window is what makes the retirement real, so it is asserted from both ends.
|
||||
*/
|
||||
class CredentialDrainWindowTest {
|
||||
|
||||
private static final ProviderProfileId PROFILE = new ProviderProfileId("ses-primary");
|
||||
private static final ProviderProfileId OTHER = new ProviderProfileId("ses-secondary");
|
||||
private static final Instant START = Instant.parse("2026-08-19T00:00:00Z");
|
||||
private static final Duration WINDOW = Duration.ofMinutes(15);
|
||||
|
||||
private final MovableClock clock = new MovableClock(START);
|
||||
private final ProviderCredentialManager manager =
|
||||
new ProviderCredentialManager(credentialKeys(), clock, WINDOW);
|
||||
|
||||
@Test
|
||||
@DisplayName("a rotation switches new work to the new generation")
|
||||
void aRotationSwitchesNewWorkToTheNewGeneration() {
|
||||
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
|
||||
manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-2"));
|
||||
|
||||
assertThat(manager.current(PROFILE).orElseThrow().generation()).isEqualTo(2);
|
||||
assertThat(manager.materialFor(PROFILE, 2)).isEqualTo(filled((byte) 0x44));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("in-flight work keeps the generation it was planned against")
|
||||
void inFlightWorkKeepsTheGenerationItWasPlannedAgainst() {
|
||||
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
|
||||
manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-2"));
|
||||
|
||||
clock.advance(WINDOW.minusSeconds(1));
|
||||
|
||||
assertThat(manager.materialFor(PROFILE, 1))
|
||||
.as("an attempt the provider may already have acted on cannot be failed or replayed")
|
||||
.isEqualTo(filled((byte) 0x33));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the retired generation stops resolving once the window closes")
|
||||
void theRetiredGenerationStopsResolvingOnceTheWindowCloses() {
|
||||
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
|
||||
manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-2"));
|
||||
|
||||
clock.advance(WINDOW);
|
||||
|
||||
assertThatThrownBy(() -> manager.materialFor(PROFILE, 1))
|
||||
.as("a superseded credential that never expires is not a rotation, it is two live keys")
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("finished draining");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a generation that was never activated is refused, drained or not")
|
||||
void aGenerationThatWasNeverActivatedIsRefused() {
|
||||
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
|
||||
|
||||
assertThatThrownBy(() -> manager.materialFor(PROFILE, 7))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("no credential generation 7");
|
||||
assertThatThrownBy(() -> manager.materialFor(OTHER, 1))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("no activated credential generation");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two profiles resolve different material")
|
||||
void twoProfilesResolveDifferentMaterial() {
|
||||
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
|
||||
manager.activate(CredentialGeneration.candidate(OTHER, 1, "cred-2"));
|
||||
|
||||
assertThat(manager.materialFor(PROFILE, 1))
|
||||
.as("one platform-wide provider credential made a leak of one account a leak of all")
|
||||
.isNotEqualTo(manager.materialFor(OTHER, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a handle naming a key of another purpose never becomes a credential")
|
||||
void aHandleNamingAKeyOfAnotherPurposeIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() -> manager.activate(CredentialGeneration.candidate(PROFILE, 1, "callback-1")))
|
||||
.as("refused at the rotation, so no dispatch can ever resolve it")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> manager.materialFor(PROFILE, 1))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a negative drain window is refused")
|
||||
void aNegativeDrainWindowIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() -> new ProviderCredentialManager(credentialKeys(), clock, Duration.ofMinutes(-1)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
/** Two provider credentials and one key of another purpose, to prove the separation holds. */
|
||||
private static SecretMaterialProvider credentialKeys() {
|
||||
return new SettingsSecretMaterialProvider(
|
||||
Map.of(
|
||||
SecretPurpose.PROVIDER_CREDENTIAL,
|
||||
new SecretKeyMaterial("cred-1", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x33)),
|
||||
SecretPurpose.CALLBACK_SIGNING,
|
||||
new SecretKeyMaterial(
|
||||
"callback-1", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x55))),
|
||||
Map.of(
|
||||
"cred-2",
|
||||
new SecretKeyMaterial(
|
||||
"cred-2", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x44))));
|
||||
}
|
||||
|
||||
private static byte[] filled(byte value) {
|
||||
byte[] material = new byte[32];
|
||||
Arrays.fill(material, value);
|
||||
return material;
|
||||
}
|
||||
|
||||
/** A clock the test moves, so the window is asserted rather than waited out. */
|
||||
private static final class MovableClock extends Clock {
|
||||
|
||||
private Instant now;
|
||||
|
||||
private MovableClock(Instant now) {
|
||||
this.now = now;
|
||||
}
|
||||
|
||||
private void advance(Duration by) {
|
||||
now = now.plus(by);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ZoneId getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(ZoneId zone) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return now;
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
-1
@@ -35,7 +35,54 @@ public final class SecurityFixtures {
|
||||
"fp-1", SecretPurpose.CALLBACK_FINGERPRINT_HMAC, filled((byte) 0x88, 32)),
|
||||
SecretPurpose.VAPID_SIGNING,
|
||||
new SecretKeyMaterial("vapid-1", SecretPurpose.VAPID_SIGNING, filled((byte) 0x66, 32))),
|
||||
Map.of());
|
||||
// A second provider credential, so a fixture can give two profiles genuinely different
|
||||
// material rather than asserting per-profile binding against one shared key.
|
||||
Map.of(
|
||||
"cred-2",
|
||||
new SecretKeyMaterial(
|
||||
"cred-2", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x45, 32)),
|
||||
"cb-2",
|
||||
new SecretKeyMaterial(
|
||||
"cb-2", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x34, 32))));
|
||||
}
|
||||
|
||||
/**
|
||||
* A credential manager holding generation 1 of each named profile.
|
||||
*
|
||||
* <p>Adapters resolve a profile's credential rather than the platform's one current provider key,
|
||||
* so a contract test has to say which profile it is speaking for — which is the point: a fixture
|
||||
* that could not name a profile was a fixture proving a shape the platform no longer has.
|
||||
*
|
||||
* @param profileIds the profiles to activate
|
||||
* @return the manager
|
||||
*/
|
||||
public static ProviderCredentialManager credentials(String... profileIds) {
|
||||
var manager = new ProviderCredentialManager(keys(), java.time.Clock.systemUTC());
|
||||
for (String profileId : profileIds) {
|
||||
activate(manager, profileId, "cred-1");
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* A credential manager holding generation 1 of one profile, backed by a named key.
|
||||
*
|
||||
* @param profileId the profile to activate
|
||||
* @param keyId which provider credential it is bound to
|
||||
* @return the manager
|
||||
*/
|
||||
public static ProviderCredentialManager credentials(String profileId, String keyId) {
|
||||
var manager = new ProviderCredentialManager(keys(), java.time.Clock.systemUTC());
|
||||
activate(manager, profileId, keyId);
|
||||
return manager;
|
||||
}
|
||||
|
||||
private static void activate(ProviderCredentialManager manager, String profileId, String keyId) {
|
||||
manager.activate(
|
||||
CredentialGeneration.candidate(
|
||||
new dev.caskeleton.application.notification.platform.api.ProviderProfileId(profileId),
|
||||
1,
|
||||
keyId));
|
||||
}
|
||||
|
||||
public static SecretMaterialProvider keysWithSameMaterial() {
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.template;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.thymeleaf.templatemode.TemplateMode;
|
||||
|
||||
/**
|
||||
* Every engine honours the slot mode, not just the one the tests happened to instantiate.
|
||||
*
|
||||
* <p>{@code SlotAwareRenderingTest} constructs {@code PlaceholderTemplateEngine} and only that, and
|
||||
* the mode-aware method was a {@code default} that forwarded to the unescaped overload. The
|
||||
* Thymeleaf engine never overrode it, so with {@code template.engine=thymeleaf} — a supported,
|
||||
* documented value — a subject could carry CR/LF and a deep link could carry a {@code javascript:}
|
||||
* scheme. Nothing failed, because the interface compiled and the one engine under test was the one
|
||||
* that implemented the rule.
|
||||
*
|
||||
* <p>Parameterized over the engines for that reason: a rule that only holds for the implementation
|
||||
* somebody remembered to test is not a rule the platform has.
|
||||
*/
|
||||
class BothEnginesHonourSlotModeTest {
|
||||
|
||||
static Stream<Arguments> engines() {
|
||||
return Stream.of(
|
||||
Arguments.of("placeholder", new PlaceholderTemplateEngine()),
|
||||
Arguments.of("thymeleaf-html", new ThymeleafStringTemplateEngine(TemplateMode.HTML)),
|
||||
Arguments.of("thymeleaf-text", new ThymeleafStringTemplateEngine(TemplateMode.TEXT)));
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("engines")
|
||||
@DisplayName("a newline in a subject is refused, whichever engine renders it")
|
||||
void aSubjectMayNotCarryAControlCharacter(String name, NotificationTemplateEngine engine) {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
engine.render(
|
||||
TemplateSlotMode.SUBJECT,
|
||||
subjectTemplate(name),
|
||||
Map.of("code", "123\r\nBcc: attacker@example.com")))
|
||||
.as("everything after a CR/LF is read as a new header by the receiving agent")
|
||||
.isInstanceOf(TemplateRenderingException.class);
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("engines")
|
||||
@DisplayName("a javascript: deep link is refused, whichever engine renders it")
|
||||
void aDeepLinkMayNotUseAScriptScheme(String name, NotificationTemplateEngine engine) {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
engine.render(
|
||||
TemplateSlotMode.URI,
|
||||
linkTemplate(name),
|
||||
Map.of("link", "javascript:alert(1)")))
|
||||
.isInstanceOf(TemplateRenderingException.class);
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("engines")
|
||||
@DisplayName("an https deep link is accepted, so the rule is a filter and not a refusal")
|
||||
void anHttpsDeepLinkIsAccepted(String name, NotificationTemplateEngine engine) {
|
||||
// Without this, the assertion above is satisfied by an engine that refuses every URI slot.
|
||||
assertThat(
|
||||
engine.render(
|
||||
TemplateSlotMode.URI, linkTemplate(name), Map.of("link", "https://example.com/a")))
|
||||
.contains("https://example.com/a");
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("engines")
|
||||
@DisplayName("markup in an HTML slot is escaped, whichever engine renders it")
|
||||
void markupInAnHtmlSlotIsEscaped(String name, NotificationTemplateEngine engine) {
|
||||
assertThat(
|
||||
engine.render(
|
||||
TemplateSlotMode.HTML_TEXT,
|
||||
bodyTemplate(name),
|
||||
Map.of("name", "<script>x</script>")))
|
||||
.as("a substituted value must not become markup")
|
||||
.doesNotContain("<script>");
|
||||
}
|
||||
|
||||
/** Each engine's own placeholder syntax; the rule under test is the escaping, not the syntax. */
|
||||
private static String subjectTemplate(String engine) {
|
||||
return engine.startsWith("thymeleaf") ? "code [[${code}]]" : "code {{code}}";
|
||||
}
|
||||
|
||||
private static String linkTemplate(String engine) {
|
||||
return engine.startsWith("thymeleaf") ? "[[${link}]]" : "{{link}}";
|
||||
}
|
||||
|
||||
private static String bodyTemplate(String engine) {
|
||||
return engine.startsWith("thymeleaf") ? "hello [[${name}]]" : "hello {{name}}";
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -107,10 +107,18 @@ public final class ContractAdapters {
|
||||
var adapter =
|
||||
new SesNotificationProviderAdapter(
|
||||
new JdkNotificationHttpGateway(Duration.ofSeconds(2)),
|
||||
new SesRequestMapper(properties, new AwsSignatureV4Signer()),
|
||||
new SesRequestMapper(
|
||||
properties,
|
||||
new AwsSignatureV4Signer(),
|
||||
new dev.caskeleton.adapter.outbound.notification.platform.provider.smtp
|
||||
.SmtpMimeMessageFactory(
|
||||
jakarta.mail.Session.getInstance(new java.util.Properties()))),
|
||||
new dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard(
|
||||
new dev.caskeleton.adapter.outbound.notification.platform.provider
|
||||
.UnconfiguredAttachmentResolver()),
|
||||
new SesFailureClassifier(),
|
||||
protector,
|
||||
SecurityFixtures.keys(),
|
||||
SecurityFixtures.credentials("ses-primary"),
|
||||
"AKIAEXAMPLE",
|
||||
CLOCK);
|
||||
return new Case(
|
||||
@@ -133,6 +141,7 @@ public final class ContractAdapters {
|
||||
Optional.of("MG123"),
|
||||
Optional.empty(),
|
||||
"https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary",
|
||||
"cb-1",
|
||||
Duration.ofSeconds(3),
|
||||
Duration.ofHours(12));
|
||||
var adapter =
|
||||
@@ -141,7 +150,7 @@ public final class ContractAdapters {
|
||||
new TwilioRequestMapper(properties),
|
||||
new TwilioFailureClassifier(),
|
||||
protector,
|
||||
SecurityFixtures.keys());
|
||||
SecurityFixtures.credentials("twilio-primary"));
|
||||
return new Case(
|
||||
"twilio",
|
||||
adapter,
|
||||
@@ -218,9 +227,11 @@ public final class ContractAdapters {
|
||||
|
||||
private static Case webhook(ProviderFaultHarness harness, ContactPointProtector protector) {
|
||||
var gateway = new JdkNotificationHttpGateway(Duration.ofSeconds(2));
|
||||
// The id of a real callback signing key, because a subscription is now signed with the key its
|
||||
// reference names rather than with whatever the platform's current one happens to be.
|
||||
var subscription =
|
||||
new WebhookSubscription(
|
||||
"sub-1", harness.baseUri().resolve("/hook"), true, Optional.of("callback-sign"));
|
||||
"sub-1", harness.baseUri().resolve("/hook"), true, Optional.of("cb-1"));
|
||||
var adapter =
|
||||
new WebhookNotificationProviderAdapter(
|
||||
gateway,
|
||||
|
||||
@@ -8,36 +8,11 @@
|
||||
// root dependencyManagement block stays awssdk-free), mirroring the grpc module's grpc-bom import.
|
||||
description = 'Outbound adapter: object storage (S3/MinIO + local filesystem)'
|
||||
|
||||
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
|
||||
|
||||
sourceSets {
|
||||
objectStorageMinioContractTest {
|
||||
java.srcDir 'src/objectStorageMinioContractTest/java'
|
||||
resources.srcDir 'src/objectStorageMinioContractTest/resources'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.test.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
objectStorageMinioFaultTest {
|
||||
java.srcDir 'src/objectStorageMinioFaultTest/java'
|
||||
resources.srcDir 'src/objectStorageMinioFaultTest/resources'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.test.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
objectStorageAwsQualificationTest {
|
||||
java.srcDir 'src/objectStorageAwsQualificationTest/java'
|
||||
resources.srcDir 'src/objectStorageAwsQualificationTest/resources'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.test.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
objectStorageMinioContractTestImplementation.extendsFrom testImplementation
|
||||
objectStorageMinioContractTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
objectStorageMinioFaultTestImplementation.extendsFrom testImplementation
|
||||
objectStorageMinioFaultTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
objectStorageAwsQualificationTestImplementation.extendsFrom testImplementation
|
||||
objectStorageAwsQualificationTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
strictTestLanes {
|
||||
sourceSet('objectStorageMinioContractTest') { compilesAgainst 'main', 'test' }
|
||||
sourceSet('objectStorageMinioFaultTest') { compilesAgainst 'main', 'test' }
|
||||
sourceSet('objectStorageAwsQualificationTest') { compilesAgainst 'main', 'test' }
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
|
||||
@@ -5,41 +5,20 @@
|
||||
// Flyway migrations live only under the .postgresql subpackage (ArchUnit keeps the base neutral).
|
||||
// The JPA relational persistence platform (docs/superpowers/specs/2026-08-11-jpa-persistence-
|
||||
// platform-design.md) models itself as 18 Stable library modules. This repository's fail-closed
|
||||
// 19-leaf registry outranks that layout, so those modules are packages here and
|
||||
// module registry outranks that layout, so those modules are packages here and
|
||||
// JpaModuleBoundaryTest enforces the design's module dependency table. The full mapping is in
|
||||
// docs/jpa/repository-adaptation.md.
|
||||
//
|
||||
// The testkit is its own source set rather than part of `test` because more than one lane consumes
|
||||
// it and because a source set whose dependencies are declared only on the test configurations gives
|
||||
// the design's "no production module depends on the testkit" guarantee without a new Gradle project.
|
||||
sourceSets {
|
||||
postgresqlIntegrationTest {
|
||||
java.setSrcDirs(['src/postgresqlIntegrationTest/java'])
|
||||
resources.setSrcDirs(['src/postgresqlIntegrationTest/resources'])
|
||||
compileClasspath += sourceSets.main.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
strictTestLanes {
|
||||
sourceSet('postgresqlIntegrationTest') {
|
||||
compilesAgainst 'main'
|
||||
inherits 'implementation', 'compileOnly', 'runtimeOnly', 'annotationProcessor'
|
||||
}
|
||||
testkit {
|
||||
java.srcDir 'src/testkit/java'
|
||||
compileClasspath += sourceSets.main.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
jpaPlatformPerformanceTest {
|
||||
java.srcDir 'src/jpaPlatformPerformanceTest/java'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.testkit.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
postgresqlIntegrationTestImplementation.extendsFrom testImplementation
|
||||
postgresqlIntegrationTestCompileOnly.extendsFrom testCompileOnly
|
||||
postgresqlIntegrationTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
postgresqlIntegrationTestAnnotationProcessor.extendsFrom testAnnotationProcessor
|
||||
testkitImplementation.extendsFrom testImplementation
|
||||
testkitRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
jpaPlatformPerformanceTestImplementation.extendsFrom testImplementation
|
||||
jpaPlatformPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
sourceSet('testkit') { compilesAgainst 'main' }
|
||||
sourceSet('jpaPlatformPerformanceTest') { compilesAgainst 'main', 'testkit' }
|
||||
}
|
||||
|
||||
// The testkit as a consumable artifact.
|
||||
@@ -49,30 +28,9 @@ configurations {
|
||||
// "domain must not depend on Hibernate" were verified as library code and applied to nothing. The
|
||||
// composition root is the only place that can see every runtime leaf at once, so it is where the
|
||||
// production suite belongs — and it needs the rules.
|
||||
tasks.register('testkitJar', Jar) {
|
||||
archiveClassifier = 'testkit'
|
||||
from sourceSets.testkit.output
|
||||
}
|
||||
|
||||
configurations {
|
||||
jpaTestkit {
|
||||
canBeConsumed = true
|
||||
canBeResolved = false
|
||||
}
|
||||
}
|
||||
|
||||
artifacts {
|
||||
jpaTestkit(tasks.named('testkitJar', Jar))
|
||||
}
|
||||
|
||||
// Every test lane compiles and runs against the testkit.
|
||||
sourceSets.test {
|
||||
compileClasspath += sourceSets.testkit.output
|
||||
runtimeClasspath += sourceSets.testkit.output
|
||||
}
|
||||
sourceSets.postgresqlIntegrationTest {
|
||||
compileClasspath += sourceSets.testkit.output
|
||||
runtimeClasspath += sourceSets.testkit.output
|
||||
testkitPublisher {
|
||||
consumedBy 'test', 'postgresqlIntegrationTest'
|
||||
publishAs 'jpaTestkit'
|
||||
}
|
||||
|
||||
ext.jpaPostgreSqlEvidenceImage = 'postgres:16-alpine'
|
||||
@@ -313,10 +271,12 @@ def jpaPlatformSecurityTest = registerJpaPlatformLane(
|
||||
// The pool behaviour contract. Named for what it does.
|
||||
//
|
||||
// It was `jpaPlatformPerformanceTest`, described as certifying pool and REQUIRES_NEW pressure, and
|
||||
// gated by `performance.assertions.enabled` — which defaulted to false everywhere, including in the
|
||||
// nightly workflow that set it explicitly to false. So the release gate depended on a lane whose
|
||||
// only threshold assertion was that thresholds were not being asserted. "Certified" described a
|
||||
// run in which no latency or throughput bound was ever compared to anything.
|
||||
// gated behind a boolean that defaulted to off everywhere it appeared — in this file, and in the
|
||||
// nightly workflow that set it explicitly to off. So the release gate depended on a lane whose only
|
||||
// threshold assertion was that thresholds were not being asserted, and "certified" described a run
|
||||
// in which no latency or throughput bound was ever compared to anything. The property is gone; its
|
||||
// name is deliberately not repeated here, because a name in a comment is the next thing somebody
|
||||
// tries to set.
|
||||
//
|
||||
// What the lane genuinely verifies is a behaviour contract: a REQUIRES_NEW depth of one needs two
|
||||
// connections per concurrent thread, a saturated pool reports its pending count, and a caller waits
|
||||
@@ -349,4 +309,48 @@ tasks.register('jpaPlatformReleaseGate') {
|
||||
dependsOn jpaPlatformPoolContractTest
|
||||
}
|
||||
|
||||
// The unit lane reads three files that are not Java sources: the release registry and its two
|
||||
// renderings. Without declaring them, Gradle calls the lane up-to-date after a registry demotion or
|
||||
// a workflow edit — so the drift check that exists to catch exactly that edit never runs on it.
|
||||
// The unit lane reads files that are not Java sources: the release registry, its two renderings,
|
||||
// and the documents that describe the pool lane. Without declaring them, Gradle calls the lane
|
||||
// up-to-date after a registry demotion or a workflow edit — so the drift checks that exist to catch
|
||||
// exactly those edits never run on them.
|
||||
tasks.named('test') {
|
||||
inputs.file(rootProject.file('config/jpa/release-registry.json'))
|
||||
inputs.file(new File(rootProject.projectDir.parentFile, 'docs/jpa/support-matrix.md'))
|
||||
inputs.file(new File(rootProject.projectDir.parentFile, 'docs/jpa/repository-adaptation.md'))
|
||||
// The whole directory, not the two named workflows: the Experimental-major check asks whether
|
||||
// *some* lane records that major as its target, so adding or deleting any workflow can change
|
||||
// its answer. Naming files here would leave the lane that matters outside the up-to-date check.
|
||||
inputs.dir(new File(rootProject.projectDir.parentFile, '.github/workflows'))
|
||||
inputs.file(file('build.gradle'))
|
||||
inputs.dir(file('src/jpaPlatformPerformanceTest/java'))
|
||||
}
|
||||
|
||||
// verifyJpaApiSurface — every public type this leaf exposes is a committed decision.
|
||||
//
|
||||
// The GraphQL and Mongo leaves already carried this; the largest of the three did not, so the one
|
||||
// public surface with the most adopters was the one nothing had an opinion about. The convention
|
||||
// plugin is opt-in per leaf, and opting in was simply never done here.
|
||||
//
|
||||
// This is a record, not a budget, and the distinction matters. A snapshot shrinks nothing on its
|
||||
// own: the GraphQL surface grew from 373 types to 398 while under one, each addition approved and
|
||||
// none refused. What the baseline buys is that growth is visible in review at the moment it
|
||||
// happens and that the number is available to argue with — not that the number cannot rise. A
|
||||
// ceiling the approval flag cannot lift is a separate decision nobody has taken yet.
|
||||
// `api` is the surface an adopter is meant to reach; everything else here is a candidate to become
|
||||
// internal at that point.
|
||||
apiSurface {
|
||||
label = 'Jpa'
|
||||
baseline = rootProject.file('../docs/architecture/jpa-api-surface.txt')
|
||||
description = 'JPA persistence leaf public API surface — every public top-level type in src/main/java.'
|
||||
rationale = [
|
||||
'A public type in a single-jar leaf is reachable from every adopter\'s code, so',
|
||||
'additions are reviewed rather than discovered. `api` is the intended external',
|
||||
'surface; the rest is implementation that has not been moved under an internal',
|
||||
'root yet.',
|
||||
]
|
||||
}
|
||||
|
||||
apply from: rootProject.file('gradle/jpa-evidence.gradle')
|
||||
|
||||
+10
-8
@@ -8,16 +8,18 @@ import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Pool pressure certification (design §38).
|
||||
* The pool behaviour a {@code REQUIRES_NEW} deployment has to satisfy (design §38).
|
||||
*
|
||||
* <p>The lane reports rather than asserts unless {@code performance.assertions.enabled} is set,
|
||||
* because the numbers depend on the machine. A shared CI runner producing a red build for a
|
||||
* threshold it never had the resources to meet teaches people to ignore the lane.
|
||||
* <p>This lane certifies nothing, and used to say it did. It was described as pool pressure
|
||||
* certification and gated behind a flag that defaulted to false everywhere it appeared, including
|
||||
* the nightly job that set it to false explicitly — so "certified" named a run in which no latency
|
||||
* or throughput bound was ever compared to anything.
|
||||
*
|
||||
* <p>What is asserted unconditionally is the arithmetic the pool has to satisfy. With {@code
|
||||
* REQUIRES_NEW}, a thread holds the outer transaction's connection while acquiring a second one, so
|
||||
* a pool sized for the thread count alone deadlocks with every connection held by a thread waiting
|
||||
* for another connection.
|
||||
* <p>What is asserted is the arithmetic, which is true on any machine and therefore needs no flag.
|
||||
* With {@code REQUIRES_NEW}, a thread holds the outer transaction's connection while acquiring a
|
||||
* second one, so a pool sized for the thread count alone deadlocks with every connection held by a
|
||||
* thread waiting for another connection. A real performance gate needs a dedicated runner, warmup
|
||||
* and sample counts and recorded thresholds; when that exists it belongs in a lane of its own.
|
||||
*/
|
||||
class PoolPressureContractTest {
|
||||
|
||||
|
||||
+23
@@ -1,5 +1,7 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.rls;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.experimental.ExperimentalFeature;
|
||||
import dev.caskeleton.adapter.outbound.persistence.experimental.ExperimentalFeatureGate;
|
||||
import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.util.Objects;
|
||||
@@ -31,6 +33,27 @@ public final class RlsTenantSessionBinder {
|
||||
/** Transaction-local binding; the {@code true} argument is what scopes it to the transaction. */
|
||||
private static final String BIND_SQL = "select set_config('app.tenant_id', ?, true)";
|
||||
|
||||
/**
|
||||
* The only way to obtain one.
|
||||
*
|
||||
* <p>The gate is asked before anything is constructed, so a deployment that never set the flag
|
||||
* cannot end up holding an instance. Taking the gate as a parameter rather than consulting a
|
||||
* static makes the requirement part of the signature: a caller cannot forget an argument the
|
||||
* compiler insists on.
|
||||
*
|
||||
* @param gate the experimental consent gate
|
||||
* @param flags the deployment's experimental flags
|
||||
* @throws IllegalStateException naming the property that must be set
|
||||
*/
|
||||
public static RlsTenantSessionBinder enabledBy(
|
||||
ExperimentalFeatureGate gate, java.util.Map<String, Boolean> flags) {
|
||||
Objects.requireNonNull(gate, "gate")
|
||||
.requireEnabled(ExperimentalFeature.MULTITENANCY_RLS, flags);
|
||||
return new RlsTenantSessionBinder();
|
||||
}
|
||||
|
||||
RlsTenantSessionBinder() {}
|
||||
|
||||
/** Binds {@code tenant} for the remainder of the current transaction. */
|
||||
public void bind(EntityManager entityManager, TenantId tenant) {
|
||||
Objects.requireNonNull(entityManager, "entityManager");
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ final class FileEntityMapper {
|
||||
Optional.ofNullable(entity.getLeaseOwner()),
|
||||
Optional.ofNullable(entity.getLeaseToken()),
|
||||
Optional.ofNullable(entity.getLeaseUntil()),
|
||||
!entity.isActive(),
|
||||
entity.getVersion(),
|
||||
entity.getCreatedAt(),
|
||||
entity.getUpdatedAt());
|
||||
|
||||
+17
@@ -118,6 +118,23 @@ public class JpaCleanupQueue implements CleanupQueue {
|
||||
return List.copyOf(claimed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int reclaimExpiredClaims(Instant now, int limit) {
|
||||
if (limit < 1) {
|
||||
throw new IllegalArgumentException("limit must be positive");
|
||||
}
|
||||
int reclaimed = 0;
|
||||
for (CleanupItemEntity abandoned : items.findExpiredClaims(now, Limit.of(limit))) {
|
||||
// Matched on the token that was read, so the reaper that loses the race changes nothing —
|
||||
// and the worker that eventually wakes up finds its own token gone and settles nothing.
|
||||
reclaimed +=
|
||||
items.reclaimExpiredClaim(
|
||||
abandoned.getCleanupId(), abandoned.getClaimToken(), clock.instant());
|
||||
heldClaims.remove(abandoned.getCleanupId());
|
||||
}
|
||||
return reclaimed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markDone(CleanupItem item) {
|
||||
Instant now = clock.instant();
|
||||
|
||||
+14
@@ -28,6 +28,10 @@ import org.springframework.stereotype.Repository;
|
||||
* <p>A lease is granted only when none is held or the held one expired, and an offset commit
|
||||
* additionally requires the exact token plus the expected offset. This is the only correctness
|
||||
* mechanism for multi-instance appends; no filesystem or NFS lock participates.
|
||||
*
|
||||
* <p>The session's own lifecycle is the second half of it. A lease answers who is writing now and
|
||||
* says nothing about whether the upload is still one anybody may write to, so cleanup could delete
|
||||
* the staged bytes of an upload a writer was about to take a lease on.
|
||||
*/
|
||||
@Repository
|
||||
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
|
||||
@@ -110,6 +114,16 @@ public class JpaUploadSessionStore implements UploadSessionStore {
|
||||
leases.releaseLease(uploadId.value(), lease.token(), clock.instant());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean terminate(UploadId uploadId) {
|
||||
return leases.terminate(uploadId.value(), clock.instant()) == 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean claimForCleanup(UploadId uploadId, Instant now) {
|
||||
return leases.claimForCleanup(uploadId.value(), now) == 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UploadSession> findExpired(Instant cutoff, int limit) {
|
||||
return sessions.findExpired(cutoff, Limit.of(limit)).stream()
|
||||
|
||||
+10
@@ -148,6 +148,16 @@ public class CleanupItemEntity {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
/** The token of the claim currently held, or {@code null} when the item is unclaimed. */
|
||||
public UUID getClaimToken() {
|
||||
return claimToken;
|
||||
}
|
||||
|
||||
/** When the held claim stops being valid, or {@code null} when there is none. */
|
||||
public Instant getLeaseUntil() {
|
||||
return leaseUntil;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
+24
@@ -16,6 +16,11 @@ import org.hibernate.type.SqlTypes;
|
||||
* <p>The lease columns are the multi-instance single-writer mechanism. They are only ever changed
|
||||
* by the conditional statements in {@code UploadLeaseRepository}, so a paused writer whose lease
|
||||
* expired cannot advance {@code committed_offset}.
|
||||
*
|
||||
* <p>{@code lifecycle_state} is the other half of that mechanism. A lease says who is writing right
|
||||
* now; it says nothing about whether the upload is still one anybody may write to, and cleanup used
|
||||
* to delete staging bytes on the strength of the lease alone. A cancelled upload therefore looked
|
||||
* exactly like an idle live one.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "fs_upload_session")
|
||||
@@ -52,6 +57,9 @@ public class UploadSessionEntity {
|
||||
@Column(name = "lease_until")
|
||||
private Instant leaseUntil;
|
||||
|
||||
@Column(name = "lifecycle_state", nullable = false, length = 16)
|
||||
private String lifecycleState;
|
||||
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private long version;
|
||||
@@ -62,6 +70,12 @@ public class UploadSessionEntity {
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
/** The state of an upload that may still be written to. */
|
||||
public static final String ACTIVE = "ACTIVE";
|
||||
|
||||
/** The state of an upload nobody may write to again. */
|
||||
public static final String TERMINAL = "TERMINAL";
|
||||
|
||||
protected UploadSessionEntity() {}
|
||||
|
||||
/** Builds a fresh upload resource at offset zero and without a lease. */
|
||||
@@ -78,6 +92,7 @@ public class UploadSessionEntity {
|
||||
this.expectedLength = expectedLength;
|
||||
this.committedOffset = 0;
|
||||
this.expiresAt = expiresAt;
|
||||
this.lifecycleState = ACTIVE;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = createdAt;
|
||||
}
|
||||
@@ -118,6 +133,15 @@ public class UploadSessionEntity {
|
||||
return leaseUntil;
|
||||
}
|
||||
|
||||
public String getLifecycleState() {
|
||||
return lifecycleState;
|
||||
}
|
||||
|
||||
/** Whether the upload may still be written to. */
|
||||
public boolean isActive() {
|
||||
return ACTIVE.equals(lifecycleState);
|
||||
}
|
||||
|
||||
public long getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
+32
@@ -99,4 +99,36 @@ public interface FileserverCleanupRepository extends JpaRepository<CleanupItemEn
|
||||
order by c.leaseUntil
|
||||
""")
|
||||
List<CleanupItemEntity> findExpiredClaims(@Param("now") Instant now, Limit limit);
|
||||
|
||||
/**
|
||||
* Returns one expired claim to the queue.
|
||||
*
|
||||
* <p>Matched on the token the reaper read, so two reapers racing for the same abandoned item
|
||||
* cannot both hand it back — and a worker that wakes up still holding that token settles nothing,
|
||||
* because {@code recordAttempt} matches on it too.
|
||||
*
|
||||
* <p>The item comes back as FAILED rather than PENDING, and its attempt counter advances. An item
|
||||
* whose worker dies every time is then bounded by the same retry budget as one that fails
|
||||
* outright, instead of being reclaimed forever.
|
||||
*
|
||||
* @return 1 when this caller reclaimed it, 0 when someone else already had
|
||||
*/
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query(
|
||||
"""
|
||||
update CleanupItemEntity c
|
||||
set c.status = 'FAILED',
|
||||
c.attempt = c.attempt + 1,
|
||||
c.nextAttemptAt = :now,
|
||||
c.lastErrorCode = 'CLAIM_LEASE_EXPIRED',
|
||||
c.claimOwner = null,
|
||||
c.claimToken = null,
|
||||
c.leaseUntil = null,
|
||||
c.updatedAt = :now
|
||||
where c.cleanupId = :cleanupId
|
||||
and c.claimToken = :token
|
||||
and c.status = 'IN_PROGRESS'
|
||||
""")
|
||||
int reclaimExpiredClaim(
|
||||
@Param("cleanupId") UUID cleanupId, @Param("token") UUID token, @Param("now") Instant now);
|
||||
}
|
||||
|
||||
+59
@@ -14,6 +14,11 @@ import org.springframework.data.repository.query.Param;
|
||||
* <p>A lease is granted only when none is held or the held one has expired, and an offset commit
|
||||
* additionally requires the exact lease token and the expected offset. Correctness never depends on
|
||||
* a filesystem or NFS lock.
|
||||
*
|
||||
* <p>Every writer statement also requires the session to be {@code ACTIVE}. Without that clause a
|
||||
* cancelled upload still handed out leases: acquire looked at the upload's expiry and the held
|
||||
* lease and at no fact about the upload's own lifecycle, so a writer could take a lease on bytes
|
||||
* that cleanup had already been asked to delete, and the two then raced for the same object.
|
||||
*/
|
||||
public interface UploadLeaseRepository extends Repository<UploadSessionEntity, UUID> {
|
||||
|
||||
@@ -29,6 +34,7 @@ public interface UploadLeaseRepository extends Repository<UploadSessionEntity, U
|
||||
where s.uploadId = :uploadId
|
||||
and s.version = :expectedVersion
|
||||
and s.expiresAt > :now
|
||||
and s.lifecycleState = 'ACTIVE'
|
||||
and (s.leaseUntil is null or s.leaseUntil <= :now)
|
||||
""")
|
||||
int acquireLease(
|
||||
@@ -49,6 +55,7 @@ public interface UploadLeaseRepository extends Repository<UploadSessionEntity, U
|
||||
where s.uploadId = :uploadId
|
||||
and s.leaseToken = :token
|
||||
and s.leaseUntil > :now
|
||||
and s.lifecycleState = 'ACTIVE'
|
||||
""")
|
||||
int renewLease(
|
||||
@Param("uploadId") UUID uploadId,
|
||||
@@ -66,6 +73,7 @@ public interface UploadLeaseRepository extends Repository<UploadSessionEntity, U
|
||||
where s.uploadId = :uploadId
|
||||
and s.leaseToken = :token
|
||||
and s.leaseUntil > :now
|
||||
and s.lifecycleState = 'ACTIVE'
|
||||
and s.committedOffset = :expectedOffset
|
||||
""")
|
||||
int commitOffset(
|
||||
@@ -89,4 +97,55 @@ public interface UploadLeaseRepository extends Repository<UploadSessionEntity, U
|
||||
""")
|
||||
int releaseLease(
|
||||
@Param("uploadId") UUID uploadId, @Param("token") UUID token, @Param("now") Instant now);
|
||||
|
||||
/**
|
||||
* Ends the upload's writable life.
|
||||
*
|
||||
* <p>Run inside the transaction that decides the upload is over — a cancel, a failed verification
|
||||
* — so the queued staging cleanup and the fact that no writer may touch those bytes again commit
|
||||
* together. Enqueuing the cleanup alone left a window in which a writer could still acquire a
|
||||
* lease on the object about to be deleted.
|
||||
*/
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query(
|
||||
"""
|
||||
update UploadSessionEntity s
|
||||
set s.lifecycleState = 'TERMINAL',
|
||||
s.version = s.version + 1,
|
||||
s.updatedAt = :now
|
||||
where s.uploadId = :uploadId
|
||||
and s.lifecycleState = 'ACTIVE'
|
||||
""")
|
||||
int terminate(@Param("uploadId") UUID uploadId, @Param("now") Instant now);
|
||||
|
||||
/**
|
||||
* Claims a terminal session whose writer lease has lapsed, for physical deletion.
|
||||
*
|
||||
* <p>A claim rather than a check. Cleanup used to read the session, see no live lease and then
|
||||
* delete — and a writer acquiring the lease in between turned that delete into the removal of an
|
||||
* active upload's bytes. Here the database decides: the lease is cleared in the same statement
|
||||
* that proves it was not held, and a writer arriving afterwards is refused by the {@code ACTIVE}
|
||||
* clause on acquire.
|
||||
*
|
||||
* <p>Idempotent on purpose. A worker that deleted the object and died before settling runs this
|
||||
* again on the retry, matches the already-cleared lease, and settles the same item once more
|
||||
* rather than being stuck.
|
||||
*
|
||||
* @return 1 when the caller may delete the staged bytes, 0 when a writer still holds the lease or
|
||||
* the session is not terminal
|
||||
*/
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query(
|
||||
"""
|
||||
update UploadSessionEntity s
|
||||
set s.leaseOwner = null,
|
||||
s.leaseToken = null,
|
||||
s.leaseUntil = null,
|
||||
s.version = s.version + 1,
|
||||
s.updatedAt = :now
|
||||
where s.uploadId = :uploadId
|
||||
and s.lifecycleState = 'TERMINAL'
|
||||
and (s.leaseUntil is null or s.leaseUntil <= :now)
|
||||
""")
|
||||
int claimForCleanup(@Param("uploadId") UUID uploadId, @Param("now") Instant now);
|
||||
}
|
||||
|
||||
+38
@@ -3,6 +3,9 @@ package dev.caskeleton.adapter.outbound.persistence.notification.platform;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/*
|
||||
* Top-level, not nested inside a holder class. Spring Data does not discover nested repository
|
||||
@@ -25,4 +28,39 @@ public interface DeduplicationClaimJpaRepository
|
||||
String category,
|
||||
String dedupKey,
|
||||
long windowBucket);
|
||||
|
||||
/**
|
||||
* Claims the deduplication window, or reports that someone else holds it.
|
||||
*
|
||||
* <p>{@code ON CONFLICT DO NOTHING} rather than an insert that may raise — the same shape, and
|
||||
* for the same reason, as {@code NotificationRequestJpaRepository.claimIdempotencyKey}. A unique
|
||||
* violation leaves the PostgreSQL transaction aborted, and the loser's whole job is to then read
|
||||
* the winner, which is a statement the database refuses until rollback. The previous
|
||||
* insert-then-catch-then-select could only work on a database that does not abort on constraint
|
||||
* violation; on PostgreSQL the recovery read was unreachable and the loser saw the follow-up
|
||||
* failure rather than the winner's notification id.
|
||||
*
|
||||
* @return 1 when this caller claimed the window, 0 when another already had
|
||||
*/
|
||||
@Modifying
|
||||
@Query(
|
||||
value =
|
||||
"INSERT INTO notification_deduplication_claim ("
|
||||
+ " id, tenant_id, recipient_ref, category, dedup_key, window_bucket,"
|
||||
+ " notification_id, created_at"
|
||||
+ ") VALUES ("
|
||||
+ " :id, :tenantId, :recipientRef, :category, :dedupKey, :windowBucket,"
|
||||
+ " :notificationId, :createdAt"
|
||||
+ ") ON CONFLICT (tenant_id, recipient_ref, category, dedup_key, window_bucket)"
|
||||
+ " DO NOTHING",
|
||||
nativeQuery = true)
|
||||
int claimWindow(
|
||||
@Param("id") UUID id,
|
||||
@Param("tenantId") String tenantId,
|
||||
@Param("recipientRef") String recipientRef,
|
||||
@Param("category") String category,
|
||||
@Param("dedupKey") String dedupKey,
|
||||
@Param("windowBucket") long windowBucket,
|
||||
@Param("notificationId") UUID notificationId,
|
||||
@Param("createdAt") java.time.Instant createdAt);
|
||||
}
|
||||
|
||||
+14
@@ -17,6 +17,7 @@ import dev.caskeleton.application.notification.platform.callback.DeliveryAttempt
|
||||
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
|
||||
import dev.caskeleton.application.notification.platform.callback.DeliveryProjection;
|
||||
import dev.caskeleton.application.notification.platform.callback.DeliveryProjectionStorePort;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderRequestIdHash;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptRecord;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.ProviderRequestIdHasherPort;
|
||||
@@ -135,6 +136,19 @@ public final class JpaDeliveryAttemptStore
|
||||
.flatMap(this::toSnapshot);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<DeliveryAttemptSnapshot> byProviderRequestIdHash(
|
||||
ProviderProfileId profileId, ProviderRequestIdHash providerRequestIdHash) {
|
||||
Objects.requireNonNull(profileId, "profileId");
|
||||
Objects.requireNonNull(providerRequestIdHash, "providerRequestIdHash");
|
||||
// The same index, reached from the side that already has the digest. A stored event never has
|
||||
// the raw identifier to hash, so the overload above cannot serve a sweep.
|
||||
return attempts
|
||||
.findByProviderProfileIdAndProviderRequestIdHash(
|
||||
profileId.value(), providerRequestIdHash.value())
|
||||
.flatMap(this::toSnapshot);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DeliveryProjection load(DeliveryAttemptId attemptId) {
|
||||
// Every fact comes back from the row. This used to read the outcome columns and then hand back
|
||||
|
||||
+35
-25
@@ -18,7 +18,6 @@ import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
/** Preference, consent and deduplication persistence. */
|
||||
public final class JpaPolicyStores {
|
||||
@@ -166,32 +165,43 @@ public final class JpaPolicyStores {
|
||||
|
||||
// Insert first and let the unique constraint decide. A read-then-write would let two
|
||||
// concurrent submissions both conclude they are the first.
|
||||
try {
|
||||
claims.saveAndFlush(
|
||||
new DeduplicationClaimEntity(
|
||||
ids.nextId(),
|
||||
recipient.tenantId().value(),
|
||||
recipient.recipientRef(),
|
||||
recipient.category(),
|
||||
dedupKey,
|
||||
windowBucket,
|
||||
candidate.value(),
|
||||
clock.instant()));
|
||||
//
|
||||
// `ON CONFLICT DO NOTHING`, not insert-and-catch. The catch branch read the winner in the
|
||||
// same
|
||||
// transaction the unique violation had just aborted, so on PostgreSQL the loser never reached
|
||||
// it: the recovery SELECT is refused until rollback, and the caller saw that refusal instead
|
||||
// of the winner's notification id. The request-key claim in this same package was rewritten
|
||||
// for exactly this reason and says so; this one was left behind.
|
||||
int claimed =
|
||||
claims.claimWindow(
|
||||
ids.nextId(),
|
||||
recipient.tenantId().value(),
|
||||
recipient.recipientRef(),
|
||||
recipient.category(),
|
||||
dedupKey,
|
||||
windowBucket,
|
||||
candidate.value(),
|
||||
clock.instant());
|
||||
if (claimed == 1) {
|
||||
return DeduplicationResult.first(candidate);
|
||||
} catch (DataIntegrityViolationException alreadyClaimed) {
|
||||
return claims
|
||||
.findByTenantIdAndRecipientRefAndCategoryAndDedupKeyAndWindowBucket(
|
||||
recipient.tenantId().value(),
|
||||
recipient.recipientRef(),
|
||||
recipient.category(),
|
||||
dedupKey,
|
||||
windowBucket)
|
||||
.map(
|
||||
existing ->
|
||||
DeduplicationResult.duplicateOf(
|
||||
candidate, new NotificationId(existing.notificationId())))
|
||||
.orElseGet(() -> DeduplicationResult.first(candidate));
|
||||
}
|
||||
// Zero rows means somebody else holds the window, and the transaction is still usable, so the
|
||||
// winner can actually be read.
|
||||
return claims
|
||||
.findByTenantIdAndRecipientRefAndCategoryAndDedupKeyAndWindowBucket(
|
||||
recipient.tenantId().value(),
|
||||
recipient.recipientRef(),
|
||||
recipient.category(),
|
||||
dedupKey,
|
||||
windowBucket)
|
||||
.map(
|
||||
existing ->
|
||||
DeduplicationResult.duplicateOf(
|
||||
candidate, new NotificationId(existing.notificationId())))
|
||||
// The row was claimed and is already gone — a window that expired between the two
|
||||
// statements. Treating this caller as first is the safe reading: it will be deduplicated
|
||||
// by the next window if the duplicate is real.
|
||||
.orElseGet(() -> DeduplicationResult.first(candidate));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -12,6 +12,7 @@ import dev.caskeleton.application.notification.platform.callback.ProviderEventLe
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderEventRecord;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderEventSource;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderRequestIdHash;
|
||||
import dev.caskeleton.application.notification.platform.callback.VerifiedProviderEvent;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.NotificationIdGeneratorPort;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.NotificationVariablesCodecPort;
|
||||
@@ -207,6 +208,15 @@ public final class JpaProviderEventLedger implements ProviderEventLedger {
|
||||
return List.copyOf(bound);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean bindAttempt(ProviderEventRecordId eventId, DeliveryAttemptId attemptId) {
|
||||
Objects.requireNonNull(eventId, "eventId");
|
||||
Objects.requireNonNull(attemptId, "attemptId");
|
||||
// The same compare-and-set the dispatch-side bind uses: whoever matched the event first keeps
|
||||
// it, and a later sweep neither rebinds it nor reports having done so.
|
||||
return events.bindAttemptIfUnbound(eventId.value(), attemptId.value()) == 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProviderEventRecord> eventsForAttempt(DeliveryAttemptId attemptId) {
|
||||
Objects.requireNonNull(attemptId, "attemptId");
|
||||
@@ -287,6 +297,10 @@ public final class JpaProviderEventLedger implements ProviderEventLedger {
|
||||
Optional.ofNullable(entity.providerOccurredAt()),
|
||||
Map.copyOf(decoded)),
|
||||
Optional.ofNullable(entity.attemptId()).map(DeliveryAttemptId::new),
|
||||
// The hash goes back out even though the raw id does not. It is the only thing a stored
|
||||
// event has to find its attempt with, and dropping it left a callback that arrived before
|
||||
// its attempt unmatchable by every later sweep.
|
||||
Optional.ofNullable(entity.providerRequestIdHash()).map(ProviderRequestIdHash::new),
|
||||
ProviderEventSource.valueOf(entity.eventSource()),
|
||||
entity.signatureVerified(),
|
||||
entity.receivedAt(),
|
||||
|
||||
+64
@@ -4,6 +4,7 @@ import dev.caskeleton.application.notification.platform.api.RecipientDeliveryId;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryRecord;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.RecipientLease;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
@@ -63,4 +64,67 @@ public final class JpaRecipientDeliveryStore implements RecipientDeliveryStorePo
|
||||
entity.transition(state.name(), nextDispatchAt.orElse(null), clock.instant());
|
||||
return mapper.toRecord(recipients.saveAndFlush(entity));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RecipientDeliveryRecord> saveHeldBy(
|
||||
RecipientDeliveryRecord record, RecipientLease lease) {
|
||||
Objects.requireNonNull(record, "record");
|
||||
Objects.requireNonNull(lease, "lease");
|
||||
// One conditional statement rather than findById → mutate → saveAndFlush. The read-modify-write
|
||||
// cannot express "only if I still hold this": by the time the entity is loaded the lease may
|
||||
// already belong to somebody else, and JPA's version column detects a concurrent edit rather
|
||||
// than a superseded writer.
|
||||
int written =
|
||||
recipients.saveProjectionHeldBy(
|
||||
record.id().value(),
|
||||
lease.owner(),
|
||||
lease.fence(),
|
||||
record.state().name(),
|
||||
record.submissionOutcome().name(),
|
||||
record.deliveryOutcome().name(),
|
||||
record.evidenceLevel().name(),
|
||||
record.ambiguousAttemptExists(),
|
||||
record.duplicateRisk(),
|
||||
record.routeCursor(),
|
||||
record.attemptCount(),
|
||||
record.lastFailureCategory().orElse(null),
|
||||
record.nextDispatchAt().orElse(null),
|
||||
clock.instant());
|
||||
return reread(written, record.id());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RecipientDeliveryRecord> transitionHeldBy(
|
||||
RecipientDeliveryId id,
|
||||
RecipientDeliveryState state,
|
||||
Optional<Instant> nextDispatchAt,
|
||||
RecipientLease lease) {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(state, "state");
|
||||
Objects.requireNonNull(nextDispatchAt, "nextDispatchAt");
|
||||
Objects.requireNonNull(lease, "lease");
|
||||
int written =
|
||||
recipients.transitionHeldBy(
|
||||
id.value(),
|
||||
lease.owner(),
|
||||
lease.fence(),
|
||||
state.name(),
|
||||
nextDispatchAt.orElse(null),
|
||||
clock.instant());
|
||||
return reread(written, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the row back after a conditional write, or reports that the lease was superseded.
|
||||
*
|
||||
* <p>The statements carry {@code clearAutomatically}, because a native update bypasses the
|
||||
* persistence context and this re-read would otherwise be served from the first-level cache with
|
||||
* the values the update just replaced.
|
||||
*/
|
||||
private Optional<RecipientDeliveryRecord> reread(int written, RecipientDeliveryId id) {
|
||||
if (written == 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return recipients.findById(id.value()).map(mapper::toRecord);
|
||||
}
|
||||
}
|
||||
|
||||
+59
@@ -53,6 +53,65 @@ public interface RecipientDeliveryJpaRepository
|
||||
@Query(value = RecipientClaimSql.EXPIRE_OVERDUE, nativeQuery = true)
|
||||
int expireOverdue(@Param("now") Instant now, @Param("batchSize") int batchSize);
|
||||
|
||||
/**
|
||||
* Writes a completion projection, and only for the holder that still owns the job.
|
||||
*
|
||||
* <p>The counterpart of {@link #renewLease}, for the write that happens *after* the provider
|
||||
* call. Everything before the submission is database work a new holder would simply redo; the
|
||||
* outcome is not — writing it under a superseded lease reports one worker's result on another
|
||||
* worker's attempt, and the two need not agree about whether the notification was sent.
|
||||
*
|
||||
* <p>Conditioned on owner and fence for the same reason as the renew: two incarnations of one
|
||||
* configured worker id share the owner string, so the fence is what distinguishes them.
|
||||
*/
|
||||
// clearAutomatically, because the caller re-reads this row immediately. A native update bypasses
|
||||
// the persistence context, so without it the re-read is served from the first-level cache with
|
||||
// the values this statement just replaced.
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query(
|
||||
value =
|
||||
"UPDATE notification_recipient_delivery "
|
||||
+ "SET delivery_state = :deliveryState, submission_outcome = :submissionOutcome, "
|
||||
+ "delivery_outcome = :deliveryOutcome, evidence_level = :evidenceLevel, "
|
||||
+ "ambiguous_attempt_exists = :ambiguousAttemptExists, duplicate_risk = :duplicateRisk, "
|
||||
+ "route_cursor = :routeCursor, attempt_count = :attemptCount, "
|
||||
+ "last_failure_category = :lastFailureCategory, next_dispatch_at = :nextDispatchAt, "
|
||||
+ "version = version + 1, updated_at = :now "
|
||||
+ "WHERE id = :id AND lease_owner = :owner AND lease_fence = :fence",
|
||||
nativeQuery = true)
|
||||
int saveProjectionHeldBy(
|
||||
@Param("id") UUID id,
|
||||
@Param("owner") String owner,
|
||||
@Param("fence") long fence,
|
||||
@Param("deliveryState") String deliveryState,
|
||||
@Param("submissionOutcome") String submissionOutcome,
|
||||
@Param("deliveryOutcome") String deliveryOutcome,
|
||||
@Param("evidenceLevel") String evidenceLevel,
|
||||
@Param("ambiguousAttemptExists") boolean ambiguousAttemptExists,
|
||||
@Param("duplicateRisk") boolean duplicateRisk,
|
||||
@Param("routeCursor") int routeCursor,
|
||||
@Param("attemptCount") int attemptCount,
|
||||
@Param("lastFailureCategory") String lastFailureCategory,
|
||||
@Param("nextDispatchAt") Instant nextDispatchAt,
|
||||
@Param("now") Instant now);
|
||||
|
||||
/** Moves a job to a state, and only for the holder that still owns it. */
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query(
|
||||
value =
|
||||
"UPDATE notification_recipient_delivery "
|
||||
+ "SET delivery_state = :deliveryState, next_dispatch_at = :nextDispatchAt, "
|
||||
+ "version = version + 1, updated_at = :now "
|
||||
+ "WHERE id = :id AND lease_owner = :owner AND lease_fence = :fence",
|
||||
nativeQuery = true)
|
||||
int transitionHeldBy(
|
||||
@Param("id") UUID id,
|
||||
@Param("owner") String owner,
|
||||
@Param("fence") long fence,
|
||||
@Param("deliveryState") String deliveryState,
|
||||
@Param("nextDispatchAt") Instant nextDispatchAt,
|
||||
@Param("now") Instant now);
|
||||
|
||||
/**
|
||||
* Extends a lease, and only for the holder that still owns it.
|
||||
*
|
||||
|
||||
+30
@@ -1,12 +1,17 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.postgresql;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.api.error.VendorFailureTranslator;
|
||||
import dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig;
|
||||
import dev.caskeleton.adapter.outbound.persistence.config.PersistenceVendorSettings;
|
||||
import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping;
|
||||
import dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository;
|
||||
import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository;
|
||||
import dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintCatalog;
|
||||
import dev.caskeleton.adapter.outbound.persistence.postgresql.error.ConstraintCatalog;
|
||||
import dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlExceptionTranslator;
|
||||
import dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.flyway.autoconfigure.FlywayConfigurationCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -58,6 +63,31 @@ public class PostgreSqlPersistenceConfig {
|
||||
return new PostgreSqlIdempotencyClaimRepository(entityManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* The SQLSTATE translator the transaction executor classifies attempt failures with.
|
||||
*
|
||||
* <p>Nothing registered one, so every composition got {@code
|
||||
* PersistenceFailureTranslatorChain.withoutCatalogs()} — a chain whose vendor stage returns the
|
||||
* failure unchanged. A serialization failure (40001) or a deadlock (40P01) therefore left an
|
||||
* attempt as a raw Spring {@code DataAccessException}, missed the retry coordinator's {@code
|
||||
* JpaPersistenceException} catch, and was never retried. The unit fixtures were green because
|
||||
* they threw an already-classified exception.
|
||||
*
|
||||
* <p>The catalog is taken from whatever the application registered and falls back to the empty
|
||||
* one. An empty catalog still classifies the SQLSTATE — which is what retry depends on — and
|
||||
* resolves constraint names to the unknown code, which is the correct answer for a constraint the
|
||||
* application never registered.
|
||||
*
|
||||
* @param catalogs the application's registered constraint catalog, if it has one
|
||||
* @return the vendor translator
|
||||
*/
|
||||
@Bean
|
||||
public VendorFailureTranslator postgreSqlVendorFailureTranslator(
|
||||
ObjectProvider<ConstraintCatalog> catalogs) {
|
||||
return PostgreSqlExceptionTranslator.with(
|
||||
catalogs.getIfAvailable(PostgreSqlConstraintCatalog::empty));
|
||||
}
|
||||
|
||||
/**
|
||||
* The vendor's migration location, as a default rather than as an override.
|
||||
*
|
||||
|
||||
+41
-2
@@ -2,6 +2,7 @@ package dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency;
|
||||
|
||||
import java.util.Objects;
|
||||
import javax.sql.DataSource;
|
||||
import org.springframework.jdbc.core.ConnectionCallback;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
@@ -21,10 +22,15 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
*/
|
||||
final class IdempotencyCapabilityGuard {
|
||||
|
||||
/** The product name the PostgreSQL JDBC driver reports. */
|
||||
private static final String POSTGRESQL_PRODUCT_NAME = "PostgreSQL";
|
||||
|
||||
private final JdbcOperations jdbc;
|
||||
private final DataSource dataSource;
|
||||
private final String activeCapabilitySql;
|
||||
|
||||
private volatile boolean postgreSqlConfirmed;
|
||||
|
||||
IdempotencyCapabilityGuard(
|
||||
JdbcOperations jdbc, DataSource dataSource, String activeCapabilitySql) {
|
||||
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
|
||||
@@ -33,11 +39,21 @@ final class IdempotencyCapabilityGuard {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails closed unless the capability's schema stream is applied and promoted.
|
||||
* Fails closed unless the database is PostgreSQL and the capability's schema stream is applied
|
||||
* and promoted.
|
||||
*
|
||||
* @throws IllegalStateException naming the capability and the revision it must reach
|
||||
* <p>Both, and in that order. The store's statements use {@code on conflict ... do nothing},
|
||||
* {@code clock_timestamp()} and {@code for update} — an H2 or MySQL deployment that selected this
|
||||
* provider would get a syntax or semantics failure somewhere inside a mutation rather than a
|
||||
* refusal before it, and the vendor is the cheaper question to answer. Neither can be settled
|
||||
* when the bean is built: the pool outlives any startup probe, and the schema can be promoted
|
||||
* after the application starts.
|
||||
*
|
||||
* @throws IllegalStateException naming the vendor found, or the capability and the revision it
|
||||
* must reach
|
||||
*/
|
||||
void requireActiveCapability() {
|
||||
requirePostgreSql();
|
||||
Integer active = jdbc.queryForObject(activeCapabilitySql, Integer.class);
|
||||
if (active == null || active != 1) {
|
||||
throw new IllegalStateException(
|
||||
@@ -45,6 +61,29 @@ final class IdempotencyCapabilityGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails closed on any vendor but PostgreSQL.
|
||||
*
|
||||
* <p>Answered once. The product name cannot change under a live pool, and asking the driver for
|
||||
* connection metadata on every claim would put a round trip in front of the statement the claim
|
||||
* is actually made of.
|
||||
*/
|
||||
private void requirePostgreSql() {
|
||||
if (postgreSqlConfirmed) {
|
||||
return;
|
||||
}
|
||||
String product =
|
||||
jdbc.execute(
|
||||
(ConnectionCallback<String>)
|
||||
connection -> connection.getMetaData().getDatabaseProductName());
|
||||
if (!POSTGRESQL_PRODUCT_NAME.equalsIgnoreCase(product)) {
|
||||
throw new IllegalStateException(
|
||||
"the owner-safe idempotency store requires PostgreSQL; this data source reports "
|
||||
+ product);
|
||||
}
|
||||
postgreSqlConfirmed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails closed unless this store's own data source is enlisted in a read-write transaction.
|
||||
*
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency;
|
||||
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimRequest;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
|
||||
/**
|
||||
* The three statements that decide who owns a scope.
|
||||
*
|
||||
* <p>Claiming is a different problem from advancing a claim already held. These three write the
|
||||
* owner tuple itself — first insert, take-over, and the abandonment of an execution whose lease ran
|
||||
* out — and every one of them is a race between processes. The transitions in {@link
|
||||
* IdempotencyTransitionGateway} are races only against the owner's own retries.
|
||||
*
|
||||
* <p>The insert is {@code on conflict ... do nothing}, so a concurrent second claimer gets zero
|
||||
* rows back rather than an exception. That matters: a unique-violation would abort the caller's
|
||||
* transaction, and the loser has to be able to read the winner's row in the same transaction to
|
||||
* find out what happened.
|
||||
*/
|
||||
final class IdempotencyClaimGateway {
|
||||
|
||||
/**
|
||||
* The principal column's value for every V2 row.
|
||||
*
|
||||
* <p>V2 identity is the scope digest. The legacy V1 columns still exist on the shared table, and
|
||||
* a sentinel makes it obvious that a V2 row's principal is not a principal rather than leaving a
|
||||
* column that looks like one and is not.
|
||||
*/
|
||||
private static final String V2_PRINCIPAL_SENTINEL = "__v2_scope_digest__";
|
||||
|
||||
private static final String INSERT_CLAIM_SQL =
|
||||
"""
|
||||
insert into idempotency_record (
|
||||
id, tenant, principal, idempotency_key, use_case_name,
|
||||
request_hash, status, response_payload, response_ref, created_at, expires_at,
|
||||
scope_hash, key_digest_version, operation_code, record_version, state_revision,
|
||||
owner_token, attempt, claim_operation_id, processing_lease_until, replay_until,
|
||||
policy_revision, response_codec_id, response_codec_version, response_digest, updated_at
|
||||
)
|
||||
select
|
||||
?, '', ?, ?, ?,
|
||||
?, 'CLAIMED', null, null, db_now,
|
||||
db_now + (? * interval '1 millisecond'),
|
||||
?, ?, ?, 2, 0,
|
||||
?, 1, ?, db_now + (? * interval '1 millisecond'), null,
|
||||
?, ?, 1, null, db_now
|
||||
from (select clock_timestamp() as db_now) authority
|
||||
on conflict (scope_hash) where record_version = 2 do nothing
|
||||
""";
|
||||
|
||||
private static final String RESET_CLAIM_SQL =
|
||||
"""
|
||||
update idempotency_record
|
||||
set idempotency_key = ?,
|
||||
use_case_name = ?,
|
||||
key_digest_version = ?,
|
||||
operation_code = ?,
|
||||
request_hash = ?,
|
||||
status = 'CLAIMED',
|
||||
state_revision = state_revision + 1,
|
||||
owner_token = ?,
|
||||
attempt = attempt + 1,
|
||||
claim_operation_id = ?,
|
||||
last_transition_operation_id = null,
|
||||
last_transition_kind = null,
|
||||
last_transition_result_digest = null,
|
||||
reconciliation_evidence_digest = null,
|
||||
processing_lease_until = clock_timestamp() + (? * interval '1 millisecond'),
|
||||
replay_until = null,
|
||||
policy_revision = ?,
|
||||
response_codec_id = ?,
|
||||
response_codec_version = 1,
|
||||
response_digest = null,
|
||||
response_payload = null,
|
||||
response_ref = null,
|
||||
failure_disposition = null,
|
||||
updated_at = clock_timestamp(),
|
||||
completed_at = null,
|
||||
expires_at = clock_timestamp() + (? * interval '1 millisecond')
|
||||
where scope_hash = ?
|
||||
and record_version = 2
|
||||
and status = ?
|
||||
and state_revision = ?
|
||||
""";
|
||||
|
||||
private static final String ABANDON_EXPIRED_EXECUTION_SQL =
|
||||
"""
|
||||
update idempotency_record
|
||||
set status = 'ABANDONED',
|
||||
state_revision = state_revision + 1,
|
||||
last_transition_operation_id = claim_operation_id,
|
||||
last_transition_kind = 'EXPIRED_EXECUTION',
|
||||
last_transition_result_digest = ?,
|
||||
failure_disposition = 'EFFECT_UNKNOWN_ABANDONED',
|
||||
updated_at = clock_timestamp()
|
||||
where scope_hash = ?
|
||||
and record_version = 2
|
||||
and status = 'EXECUTING'
|
||||
and owner_token = ?
|
||||
and attempt = ?
|
||||
and claim_operation_id = ?
|
||||
and state_revision = ?
|
||||
""";
|
||||
|
||||
private final JdbcOperations jdbc;
|
||||
|
||||
IdempotencyClaimGateway(JdbcOperations jdbc) {
|
||||
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a first claim.
|
||||
*
|
||||
* @return {@code 1} when this caller created the row, {@code 0} when one already existed
|
||||
*/
|
||||
int insert(IdempotencyClaimRequest request) {
|
||||
return jdbc.update(
|
||||
INSERT_CLAIM_SQL,
|
||||
UUID.randomUUID(),
|
||||
V2_PRINCIPAL_SENTINEL,
|
||||
request.scope().digest(),
|
||||
request.scope().operationCode(),
|
||||
request.requestFingerprint().hex(),
|
||||
request.replayTtl().toMillis(),
|
||||
request.scope().digest(),
|
||||
request.scope().keyDigestVersion(),
|
||||
request.scope().operationCode(),
|
||||
request.claimAttempt().ownerToken(),
|
||||
request.claimAttempt().operationId().value(),
|
||||
request.processingLeaseTtl().toMillis(),
|
||||
request.policyRevision(),
|
||||
request.responseCodecId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a reclaimable row over, repeating the state it was read in.
|
||||
*
|
||||
* @return {@code 1} when the row was still in the state the caller read, {@code 0} otherwise
|
||||
*/
|
||||
int reset(IdempotencyClaimRequest request, IdempotencyRecordRow row) {
|
||||
return jdbc.update(
|
||||
RESET_CLAIM_SQL,
|
||||
request.scope().digest(),
|
||||
request.scope().operationCode(),
|
||||
request.scope().keyDigestVersion(),
|
||||
request.scope().operationCode(),
|
||||
request.requestFingerprint().hex(),
|
||||
request.claimAttempt().ownerToken(),
|
||||
request.claimAttempt().operationId().value(),
|
||||
request.processingLeaseTtl().toMillis(),
|
||||
request.policyRevision(),
|
||||
request.responseCodecId(),
|
||||
request.replayTtl().toMillis(),
|
||||
request.scope().digest(),
|
||||
row.state().name(),
|
||||
row.stateRevision());
|
||||
}
|
||||
|
||||
/**
|
||||
* Abandons an execution whose lease expired, repeating the whole owner tuple.
|
||||
*
|
||||
* @return {@code 1} when the expired owner was still the recorded one, {@code 0} otherwise
|
||||
*/
|
||||
int abandonExpiredExecution(IdempotencyRecordRow row, String resultDigest) {
|
||||
return jdbc.update(
|
||||
ABANDON_EXPIRED_EXECUTION_SQL,
|
||||
resultDigest,
|
||||
row.scopeHash(),
|
||||
row.ownerToken(),
|
||||
row.attempt(),
|
||||
row.claimOperationId(),
|
||||
row.stateRevision());
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency;
|
||||
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyState;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* One owner-safe idempotency row, as the database holds it.
|
||||
*
|
||||
* <p>Package-private, and deliberately not part of the port's vocabulary: the application sees
|
||||
* claim and transition outcomes, never a row. Keeping the row a type of its own is what lets the
|
||||
* reader, the two gateways and the store share one definition of what a row contains instead of
|
||||
* each re-deriving it from the columns it happens to select.
|
||||
*
|
||||
* @param stateRevision the counter every owner CAS repeats; a transition that does not carry the
|
||||
* revision it read is a transition that can be applied twice
|
||||
*/
|
||||
record IdempotencyRecordRow(
|
||||
String scopeHash,
|
||||
int keyDigestVersion,
|
||||
String operationCode,
|
||||
String requestHash,
|
||||
IdempotencyState state,
|
||||
long stateRevision,
|
||||
String ownerToken,
|
||||
long attempt,
|
||||
String claimOperationId,
|
||||
String lastTransitionOperationId,
|
||||
String lastTransitionKind,
|
||||
String lastTransitionResultDigest,
|
||||
Instant processingLeaseUntil,
|
||||
Instant replayUntil,
|
||||
String responsePayload,
|
||||
String responseDigest,
|
||||
String responseCodecId,
|
||||
int policyRevision,
|
||||
Instant expiresAt) {}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency;
|
||||
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyState;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
|
||||
/**
|
||||
* Reads owner-safe idempotency rows, and the database's own clock.
|
||||
*
|
||||
* <p>Split out because reading has nothing to say about the state machine. The store decides which
|
||||
* transition a row permits; this decides what a row is, which columns carry it, and how a timestamp
|
||||
* crosses the JDBC boundary. Editing one transition's SQL used to mean editing the same file as the
|
||||
* column list every other transition reads back.
|
||||
*
|
||||
* <p>The time comes from {@code clock_timestamp()} and is read <em>after</em> the row is locked. A
|
||||
* lease comparison against the application's clock decides expiry from a machine that is not the
|
||||
* one that wrote the lease, and reading the database's clock before taking the lock decides it from
|
||||
* a moment before the row could have changed.
|
||||
*/
|
||||
final class IdempotencyRowMapper {
|
||||
|
||||
private static final String SELECT_ROW_SQL =
|
||||
"""
|
||||
select scope_hash, key_digest_version, operation_code, request_hash, status,
|
||||
state_revision, owner_token, attempt, claim_operation_id,
|
||||
last_transition_operation_id, last_transition_kind,
|
||||
last_transition_result_digest, processing_lease_until, replay_until,
|
||||
response_payload, response_digest, response_codec_id, policy_revision, expires_at
|
||||
from idempotency_record
|
||||
where scope_hash = ?
|
||||
and record_version = 2
|
||||
""";
|
||||
|
||||
private static final String SELECT_ROW_FOR_UPDATE_SQL = SELECT_ROW_SQL + " for update";
|
||||
|
||||
private static final String DB_NOW_SQL = "select clock_timestamp()";
|
||||
|
||||
private final JdbcOperations jdbc;
|
||||
|
||||
IdempotencyRowMapper(JdbcOperations jdbc) {
|
||||
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
|
||||
}
|
||||
|
||||
/** The row for a scope digest, without taking a lock. */
|
||||
Optional<IdempotencyRecordRow> find(String scopeHash) {
|
||||
return queryOne(SELECT_ROW_SQL, scopeHash);
|
||||
}
|
||||
|
||||
/** The row for a scope digest, locked for the rest of the transaction. */
|
||||
Optional<IdempotencyRecordRow> findForUpdate(String scopeHash) {
|
||||
return queryOne(SELECT_ROW_FOR_UPDATE_SQL, scopeHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* The database's own current time.
|
||||
*
|
||||
* @throws IllegalStateException when the server returned no time at all, which would otherwise
|
||||
* become a null lease comparison and a silently expired claim
|
||||
*/
|
||||
Instant databaseNow() {
|
||||
OffsetDateTime value = jdbc.queryForObject(DB_NOW_SQL, OffsetDateTime.class);
|
||||
if (value == null) {
|
||||
throw new IllegalStateException("PostgreSQL returned no authoritative database time");
|
||||
}
|
||||
return value.toInstant();
|
||||
}
|
||||
|
||||
private Optional<IdempotencyRecordRow> queryOne(String sql, String scopeHash) {
|
||||
List<IdempotencyRecordRow> rows = jdbc.query(sql, IdempotencyRowMapper::mapRow, scopeHash);
|
||||
if (rows.size() > 1) {
|
||||
throw new IllegalStateException("multiple idempotency V2 rows for one scope digest");
|
||||
}
|
||||
return rows.stream().findFirst();
|
||||
}
|
||||
|
||||
private static IdempotencyRecordRow mapRow(ResultSet resultSet, int rowNumber)
|
||||
throws SQLException {
|
||||
return new IdempotencyRecordRow(
|
||||
resultSet.getString("scope_hash"),
|
||||
resultSet.getInt("key_digest_version"),
|
||||
resultSet.getString("operation_code"),
|
||||
resultSet.getString("request_hash"),
|
||||
IdempotencyState.valueOf(resultSet.getString("status")),
|
||||
resultSet.getLong("state_revision"),
|
||||
resultSet.getString("owner_token"),
|
||||
resultSet.getLong("attempt"),
|
||||
resultSet.getString("claim_operation_id"),
|
||||
resultSet.getString("last_transition_operation_id"),
|
||||
resultSet.getString("last_transition_kind"),
|
||||
resultSet.getString("last_transition_result_digest"),
|
||||
instant(resultSet, "processing_lease_until"),
|
||||
nullableInstant(resultSet, "replay_until"),
|
||||
resultSet.getString("response_payload"),
|
||||
resultSet.getString("response_digest"),
|
||||
resultSet.getString("response_codec_id"),
|
||||
resultSet.getInt("policy_revision"),
|
||||
instant(resultSet, "expires_at"));
|
||||
}
|
||||
|
||||
private static Instant instant(ResultSet resultSet, String column) throws SQLException {
|
||||
return resultSet.getObject(column, OffsetDateTime.class).toInstant();
|
||||
}
|
||||
|
||||
private static Instant nullableInstant(ResultSet resultSet, String column) throws SQLException {
|
||||
OffsetDateTime value = resultSet.getObject(column, OffsetDateTime.class);
|
||||
return value == null ? null : value.toInstant();
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency;
|
||||
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyOwner;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
|
||||
/**
|
||||
* The statements that advance a claim its owner already holds.
|
||||
*
|
||||
* <p>Every one repeats the complete owner tuple — scope, token, attempt, claim operation and state
|
||||
* revision — in its {@code where} clause, so the update count <em>is</em> the answer: one row means
|
||||
* this owner was still the owner at this revision, zero means something else moved the record and
|
||||
* the caller must not treat its own view as current. Reading the row and then updating on the scope
|
||||
* alone would let a worker whose lease expired overwrite the state of the one that took over.
|
||||
*
|
||||
* <p>Collected here rather than in the store because they are one family: same guard, same
|
||||
* interpretation of the count, same reason a caller may not skip the guard. The store decides which
|
||||
* of them a given outcome permits.
|
||||
*/
|
||||
final class IdempotencyTransitionGateway {
|
||||
|
||||
private static final String START_SQL =
|
||||
"""
|
||||
update idempotency_record
|
||||
set status = 'EXECUTING',
|
||||
state_revision = state_revision + 1,
|
||||
last_transition_operation_id = ?,
|
||||
last_transition_kind = 'START',
|
||||
last_transition_result_digest = ?,
|
||||
updated_at = clock_timestamp()
|
||||
where scope_hash = ?
|
||||
and record_version = 2
|
||||
and status = 'CLAIMED'
|
||||
and owner_token = ?
|
||||
and attempt = ?
|
||||
and claim_operation_id = ?
|
||||
and state_revision = ?
|
||||
and processing_lease_until > clock_timestamp()
|
||||
""";
|
||||
|
||||
private static final String RENEW_SQL =
|
||||
"""
|
||||
update idempotency_record
|
||||
set state_revision = state_revision + 1,
|
||||
processing_lease_until = clock_timestamp() + (? * interval '1 millisecond'),
|
||||
last_transition_operation_id = ?,
|
||||
last_transition_kind = 'RENEW',
|
||||
last_transition_result_digest = ?,
|
||||
updated_at = clock_timestamp()
|
||||
where scope_hash = ?
|
||||
and record_version = 2
|
||||
and status in ('CLAIMED', 'EXECUTING')
|
||||
and owner_token = ?
|
||||
and attempt = ?
|
||||
and claim_operation_id = ?
|
||||
and state_revision = ?
|
||||
and processing_lease_until > clock_timestamp()
|
||||
""";
|
||||
|
||||
private static final String COMPLETE_SQL =
|
||||
"""
|
||||
update idempotency_record
|
||||
set status = 'COMPLETED',
|
||||
state_revision = state_revision + 1,
|
||||
last_transition_operation_id = ?,
|
||||
last_transition_kind = 'COMPLETE',
|
||||
last_transition_result_digest = ?,
|
||||
response_payload = ?,
|
||||
response_ref = null,
|
||||
response_digest = ?,
|
||||
replay_until = clock_timestamp() + (? * interval '1 millisecond'),
|
||||
completed_at = clock_timestamp(),
|
||||
updated_at = clock_timestamp(),
|
||||
expires_at = clock_timestamp() + (? * interval '1 millisecond')
|
||||
where scope_hash = ?
|
||||
and record_version = 2
|
||||
and status = 'EXECUTING'
|
||||
and owner_token = ?
|
||||
and attempt = ?
|
||||
and claim_operation_id = ?
|
||||
and state_revision = ?
|
||||
""";
|
||||
|
||||
private static final String FAIL_SQL =
|
||||
"""
|
||||
update idempotency_record
|
||||
set status = ?,
|
||||
state_revision = state_revision + 1,
|
||||
last_transition_operation_id = ?,
|
||||
last_transition_kind = ?,
|
||||
last_transition_result_digest = ?,
|
||||
failure_disposition = ?,
|
||||
processing_lease_until = clock_timestamp(),
|
||||
updated_at = clock_timestamp(),
|
||||
expires_at = clock_timestamp() + (? * interval '1 millisecond')
|
||||
where scope_hash = ?
|
||||
and record_version = 2
|
||||
and status = 'EXECUTING'
|
||||
and owner_token = ?
|
||||
and attempt = ?
|
||||
and claim_operation_id = ?
|
||||
and state_revision = ?
|
||||
""";
|
||||
|
||||
private static final String RELEASE_SQL =
|
||||
"""
|
||||
update idempotency_record
|
||||
set status = 'FAILED_RETRYABLE',
|
||||
state_revision = state_revision + 1,
|
||||
last_transition_operation_id = ?,
|
||||
last_transition_kind = 'RELEASE',
|
||||
last_transition_result_digest = ?,
|
||||
failure_disposition = 'NO_EFFECT_RETRYABLE',
|
||||
processing_lease_until = clock_timestamp(),
|
||||
updated_at = clock_timestamp()
|
||||
where scope_hash = ?
|
||||
and record_version = 2
|
||||
and status = 'CLAIMED'
|
||||
and owner_token = ?
|
||||
and attempt = ?
|
||||
and claim_operation_id = ?
|
||||
and state_revision = ?
|
||||
""";
|
||||
|
||||
private final JdbcOperations jdbc;
|
||||
|
||||
IdempotencyTransitionGateway(JdbcOperations jdbc) {
|
||||
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
|
||||
}
|
||||
|
||||
/** Moves a claim into execution. */
|
||||
int start(IdempotencyOwner owner, String operationId, String resultDigest) {
|
||||
return jdbc.update(
|
||||
START_SQL,
|
||||
operationId,
|
||||
resultDigest,
|
||||
owner.scope().digest(),
|
||||
owner.ownerToken(),
|
||||
owner.attempt(),
|
||||
owner.claimOperationId().value(),
|
||||
owner.stateRevision());
|
||||
}
|
||||
|
||||
/** Extends the processing lease. */
|
||||
int renew(
|
||||
IdempotencyOwner owner,
|
||||
Duration processingLeaseTtl,
|
||||
String operationId,
|
||||
String resultDigest) {
|
||||
return jdbc.update(
|
||||
RENEW_SQL,
|
||||
processingLeaseTtl.toMillis(),
|
||||
operationId,
|
||||
resultDigest,
|
||||
owner.scope().digest(),
|
||||
owner.ownerToken(),
|
||||
owner.attempt(),
|
||||
owner.claimOperationId().value(),
|
||||
owner.stateRevision());
|
||||
}
|
||||
|
||||
/** Records the response and opens the replay window. */
|
||||
int complete(
|
||||
IdempotencyOwner owner,
|
||||
String operationId,
|
||||
String resultDigest,
|
||||
String payload,
|
||||
String responseDigest,
|
||||
Duration replayTtl) {
|
||||
return jdbc.update(
|
||||
COMPLETE_SQL,
|
||||
operationId,
|
||||
resultDigest,
|
||||
payload,
|
||||
responseDigest,
|
||||
replayTtl.toMillis(),
|
||||
replayTtl.toMillis(),
|
||||
owner.scope().digest(),
|
||||
owner.ownerToken(),
|
||||
owner.attempt(),
|
||||
owner.claimOperationId().value(),
|
||||
owner.stateRevision());
|
||||
}
|
||||
|
||||
/** Records a terminal failure and the disposition it carries. */
|
||||
int fail(
|
||||
IdempotencyOwner owner,
|
||||
String targetState,
|
||||
String operationId,
|
||||
String transitionKind,
|
||||
String resultDigest,
|
||||
String disposition,
|
||||
Duration retention) {
|
||||
return jdbc.update(
|
||||
FAIL_SQL,
|
||||
targetState,
|
||||
operationId,
|
||||
transitionKind,
|
||||
resultDigest,
|
||||
disposition,
|
||||
retention.toMillis(),
|
||||
owner.scope().digest(),
|
||||
owner.ownerToken(),
|
||||
owner.attempt(),
|
||||
owner.claimOperationId().value(),
|
||||
owner.stateRevision());
|
||||
}
|
||||
|
||||
/** Gives a claim back before any execution began. */
|
||||
int release(IdempotencyOwner owner, String operationId, String resultDigest) {
|
||||
return jdbc.update(
|
||||
RELEASE_SQL,
|
||||
operationId,
|
||||
resultDigest,
|
||||
owner.scope().digest(),
|
||||
owner.ownerToken(),
|
||||
owner.attempt(),
|
||||
owner.claimOperationId().value(),
|
||||
owner.stateRevision());
|
||||
}
|
||||
}
|
||||
+168
-430
@@ -14,6 +14,7 @@ import dev.caskeleton.application.idempotency.v2.IdempotencyMutationResult;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyOwner;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyReleaseOutcome;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyRenewOutcome;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyScopeDigest;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyStartOutcome;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyState;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyStorePortV2;
|
||||
@@ -22,16 +23,11 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
|
||||
/**
|
||||
@@ -41,17 +37,26 @@ import org.springframework.jdbc.core.JdbcOperations;
|
||||
* before {@code clock_timestamp()} is evaluated, and every state change repeats the complete owner
|
||||
* CAS tuple in SQL. Raw client idempotency keys never reach this adapter.
|
||||
*
|
||||
* <p>Deliberately carries no Spring stereotype. Both composition roots component-scan {@code
|
||||
* dev.caskeleton.adapter}, so a {@code @Repository} here was registered in every deployment
|
||||
* regardless of which idempotency provider was selected: {@code provider=jdbc} acquired an
|
||||
* owner-safe V2 store it never asked for, and {@code provider=redis} acquired a second one beside
|
||||
* its own. Both counts are what {@code IdempotencyProviderSelectionConfig} refuses, so a scan-
|
||||
* registered store meant neither selection could start.
|
||||
* <p>What is left here is the port and the decisions the port's outcomes encode: which state a row
|
||||
* is in, whether this caller still owns it, whether the request is a replay of a transition already
|
||||
* applied, and which outcome each of those answers produces. The SQL that reads a row, the three
|
||||
* statements that decide ownership, the five that advance it, the digest composition and the
|
||||
* preconditions are each a collaborator of their own. That split is not cosmetic — this class held
|
||||
* all of them, so changing one transition's SQL meant editing the same file as the row mapper and
|
||||
* the hashing, and the transaction precondition sat next to the string constants it has nothing to
|
||||
* do with.
|
||||
*
|
||||
* <p>{@code ca-skeleton.capabilities.idempotency.provider} is {@code disabled | jdbc | redis} and
|
||||
* has no value that selects this store, so nothing composes it today; the integration test
|
||||
* constructs it directly. Giving it a selector is outstanding work, and it belongs with the
|
||||
* registry entry for that property rather than with a stereotype that composes it everywhere.
|
||||
* <p>The collaborators are assembled in the constructor rather than injected. They are parts of
|
||||
* this store, not services an application composes or replaces, and injecting them would widen the
|
||||
* public bean surface from one type to six for no gain.
|
||||
*
|
||||
* <p>Deliberately carries no Spring stereotype. Both composition roots component-scan {@code
|
||||
* dev.caskeleton.adapter}, so a {@code @Repository} here would be registered in every deployment
|
||||
* regardless of which idempotency provider was selected: {@code provider=jdbc} would acquire an
|
||||
* owner-safe V2 store it never asked for, and {@code provider=redis} a second one beside its own.
|
||||
* Both counts are what {@code IdempotencyProviderSelectionConfig} refuses, so a scan-registered
|
||||
* store means neither selection can start. The composition root builds it for {@code
|
||||
* provider=postgresql} and only there.
|
||||
*/
|
||||
public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePortV2 {
|
||||
|
||||
@@ -59,8 +64,6 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
|
||||
private static final Duration MAXIMUM_PROCESSING_LEASE = Duration.ofHours(1);
|
||||
private static final Duration MAXIMUM_RETENTION = Duration.ofDays(30);
|
||||
private static final String V2_PRINCIPAL_SENTINEL = "__v2_scope_digest__";
|
||||
private static final String DB_NOW_SQL = "select clock_timestamp()";
|
||||
|
||||
private static final String ACTIVE_CAPABILITY_SQL =
|
||||
"""
|
||||
@@ -72,216 +75,23 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
and lifecycle_state = 'ACTIVE'
|
||||
""";
|
||||
|
||||
private static final String INSERT_CLAIM_SQL =
|
||||
"""
|
||||
insert into idempotency_record (
|
||||
id, tenant, principal, idempotency_key, use_case_name,
|
||||
request_hash, status, response_payload, response_ref, created_at, expires_at,
|
||||
scope_hash, key_digest_version, operation_code, record_version, state_revision,
|
||||
owner_token, attempt, claim_operation_id, processing_lease_until, replay_until,
|
||||
policy_revision, response_codec_id, response_codec_version, response_digest, updated_at
|
||||
)
|
||||
select
|
||||
?, '', ?, ?, ?,
|
||||
?, 'CLAIMED', null, null, db_now,
|
||||
db_now + (? * interval '1 millisecond'),
|
||||
?, ?, ?, 2, 0,
|
||||
?, 1, ?, db_now + (? * interval '1 millisecond'), null,
|
||||
?, ?, 1, null, db_now
|
||||
from (select clock_timestamp() as db_now) authority
|
||||
on conflict (scope_hash) where record_version = 2 do nothing
|
||||
""";
|
||||
|
||||
private static final String SELECT_ROW_SQL =
|
||||
"""
|
||||
select scope_hash, key_digest_version, operation_code, request_hash, status,
|
||||
state_revision, owner_token, attempt, claim_operation_id,
|
||||
last_transition_operation_id, last_transition_kind,
|
||||
last_transition_result_digest, processing_lease_until, replay_until,
|
||||
response_payload, response_digest, response_codec_id, policy_revision, expires_at
|
||||
from idempotency_record
|
||||
where scope_hash = ?
|
||||
and record_version = 2
|
||||
""";
|
||||
|
||||
private static final String SELECT_ROW_FOR_UPDATE_SQL = SELECT_ROW_SQL + " for update";
|
||||
|
||||
private static final String RESET_CLAIM_SQL =
|
||||
"""
|
||||
update idempotency_record
|
||||
set idempotency_key = ?,
|
||||
use_case_name = ?,
|
||||
key_digest_version = ?,
|
||||
operation_code = ?,
|
||||
request_hash = ?,
|
||||
status = 'CLAIMED',
|
||||
state_revision = state_revision + 1,
|
||||
owner_token = ?,
|
||||
attempt = attempt + 1,
|
||||
claim_operation_id = ?,
|
||||
last_transition_operation_id = null,
|
||||
last_transition_kind = null,
|
||||
last_transition_result_digest = null,
|
||||
reconciliation_evidence_digest = null,
|
||||
processing_lease_until = clock_timestamp() + (? * interval '1 millisecond'),
|
||||
replay_until = null,
|
||||
policy_revision = ?,
|
||||
response_codec_id = ?,
|
||||
response_codec_version = 1,
|
||||
response_digest = null,
|
||||
response_payload = null,
|
||||
response_ref = null,
|
||||
failure_disposition = null,
|
||||
updated_at = clock_timestamp(),
|
||||
completed_at = null,
|
||||
expires_at = clock_timestamp() + (? * interval '1 millisecond')
|
||||
where scope_hash = ?
|
||||
and record_version = 2
|
||||
and status = ?
|
||||
and state_revision = ?
|
||||
""";
|
||||
|
||||
private static final String ABANDON_EXPIRED_EXECUTION_SQL =
|
||||
"""
|
||||
update idempotency_record
|
||||
set status = 'ABANDONED',
|
||||
state_revision = state_revision + 1,
|
||||
last_transition_operation_id = claim_operation_id,
|
||||
last_transition_kind = 'EXPIRED_EXECUTION',
|
||||
last_transition_result_digest = ?,
|
||||
failure_disposition = 'EFFECT_UNKNOWN_ABANDONED',
|
||||
updated_at = clock_timestamp()
|
||||
where scope_hash = ?
|
||||
and record_version = 2
|
||||
and status = 'EXECUTING'
|
||||
and owner_token = ?
|
||||
and attempt = ?
|
||||
and claim_operation_id = ?
|
||||
and state_revision = ?
|
||||
""";
|
||||
|
||||
private static final String START_SQL =
|
||||
"""
|
||||
update idempotency_record
|
||||
set status = 'EXECUTING',
|
||||
state_revision = state_revision + 1,
|
||||
last_transition_operation_id = ?,
|
||||
last_transition_kind = 'START',
|
||||
last_transition_result_digest = ?,
|
||||
updated_at = clock_timestamp()
|
||||
where scope_hash = ?
|
||||
and record_version = 2
|
||||
and status = 'CLAIMED'
|
||||
and owner_token = ?
|
||||
and attempt = ?
|
||||
and claim_operation_id = ?
|
||||
and state_revision = ?
|
||||
and processing_lease_until > clock_timestamp()
|
||||
""";
|
||||
|
||||
private static final String RENEW_SQL =
|
||||
"""
|
||||
update idempotency_record
|
||||
set state_revision = state_revision + 1,
|
||||
processing_lease_until = clock_timestamp() + (? * interval '1 millisecond'),
|
||||
last_transition_operation_id = ?,
|
||||
last_transition_kind = 'RENEW',
|
||||
last_transition_result_digest = ?,
|
||||
updated_at = clock_timestamp()
|
||||
where scope_hash = ?
|
||||
and record_version = 2
|
||||
and status in ('CLAIMED', 'EXECUTING')
|
||||
and owner_token = ?
|
||||
and attempt = ?
|
||||
and claim_operation_id = ?
|
||||
and state_revision = ?
|
||||
and processing_lease_until > clock_timestamp()
|
||||
""";
|
||||
|
||||
private static final String COMPLETE_SQL =
|
||||
"""
|
||||
update idempotency_record
|
||||
set status = 'COMPLETED',
|
||||
state_revision = state_revision + 1,
|
||||
last_transition_operation_id = ?,
|
||||
last_transition_kind = 'COMPLETE',
|
||||
last_transition_result_digest = ?,
|
||||
response_payload = ?,
|
||||
response_ref = null,
|
||||
response_digest = ?,
|
||||
replay_until = clock_timestamp() + (? * interval '1 millisecond'),
|
||||
completed_at = clock_timestamp(),
|
||||
updated_at = clock_timestamp(),
|
||||
expires_at = clock_timestamp() + (? * interval '1 millisecond')
|
||||
where scope_hash = ?
|
||||
and record_version = 2
|
||||
and status = 'EXECUTING'
|
||||
and owner_token = ?
|
||||
and attempt = ?
|
||||
and claim_operation_id = ?
|
||||
and state_revision = ?
|
||||
""";
|
||||
|
||||
private static final String FAIL_SQL =
|
||||
"""
|
||||
update idempotency_record
|
||||
set status = ?,
|
||||
state_revision = state_revision + 1,
|
||||
last_transition_operation_id = ?,
|
||||
last_transition_kind = ?,
|
||||
last_transition_result_digest = ?,
|
||||
failure_disposition = ?,
|
||||
processing_lease_until = clock_timestamp(),
|
||||
updated_at = clock_timestamp(),
|
||||
expires_at = clock_timestamp() + (? * interval '1 millisecond')
|
||||
where scope_hash = ?
|
||||
and record_version = 2
|
||||
and status = 'EXECUTING'
|
||||
and owner_token = ?
|
||||
and attempt = ?
|
||||
and claim_operation_id = ?
|
||||
and state_revision = ?
|
||||
""";
|
||||
|
||||
private static final String RELEASE_SQL =
|
||||
"""
|
||||
update idempotency_record
|
||||
set status = 'FAILED_RETRYABLE',
|
||||
state_revision = state_revision + 1,
|
||||
last_transition_operation_id = ?,
|
||||
last_transition_kind = 'RELEASE',
|
||||
last_transition_result_digest = ?,
|
||||
failure_disposition = 'NO_EFFECT_RETRYABLE',
|
||||
processing_lease_until = clock_timestamp(),
|
||||
updated_at = clock_timestamp()
|
||||
where scope_hash = ?
|
||||
and record_version = 2
|
||||
and status = 'CLAIMED'
|
||||
and owner_token = ?
|
||||
and attempt = ?
|
||||
and claim_operation_id = ?
|
||||
and state_revision = ?
|
||||
""";
|
||||
|
||||
private final JdbcOperations jdbc;
|
||||
private final SecureRandom secureRandom;
|
||||
|
||||
/**
|
||||
* Schema activation and transaction preconditions, assembled here rather than injected.
|
||||
*
|
||||
* <p>Explicit construction keeps the public bean surface at one type. These are collaborators of
|
||||
* this store, not services an application composes or replaces.
|
||||
*/
|
||||
private final IdempotencyCapabilityGuard guard;
|
||||
private final IdempotencyRowMapper rows;
|
||||
private final IdempotencyClaimGateway claims;
|
||||
private final IdempotencyTransitionGateway transitions;
|
||||
|
||||
public PostgreSqlOwnerSafeIdempotencyStore(JdbcOperations jdbc) {
|
||||
this(jdbc, new SecureRandom());
|
||||
}
|
||||
|
||||
PostgreSqlOwnerSafeIdempotencyStore(JdbcOperations jdbc, SecureRandom secureRandom) {
|
||||
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
|
||||
Objects.requireNonNull(jdbc, "jdbc");
|
||||
this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom");
|
||||
this.guard = new IdempotencyCapabilityGuard(jdbc, dataSourceOf(jdbc), ACTIVE_CAPABILITY_SQL);
|
||||
this.rows = new IdempotencyRowMapper(jdbc);
|
||||
this.claims = new IdempotencyClaimGateway(jdbc);
|
||||
this.transitions = new IdempotencyTransitionGateway(jdbc);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,26 +122,11 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
requirePrimaryWriteTransaction();
|
||||
requireActiveCapability();
|
||||
|
||||
int inserted =
|
||||
jdbc.update(
|
||||
INSERT_CLAIM_SQL,
|
||||
UUID.randomUUID(),
|
||||
V2_PRINCIPAL_SENTINEL,
|
||||
request.scope().digest(),
|
||||
request.scope().operationCode(),
|
||||
request.requestFingerprint().hex(),
|
||||
request.replayTtl().toMillis(),
|
||||
request.scope().digest(),
|
||||
request.scope().keyDigestVersion(),
|
||||
request.scope().operationCode(),
|
||||
request.claimAttempt().ownerToken(),
|
||||
request.claimAttempt().operationId().value(),
|
||||
request.processingLeaseTtl().toMillis(),
|
||||
request.policyRevision(),
|
||||
request.responseCodecId());
|
||||
int inserted = claims.insert(request);
|
||||
|
||||
Row row = findForUpdate(request.scope().digest()).orElseThrow(this::indeterminateClaim);
|
||||
Instant dbNow = databaseNowAfterLock();
|
||||
IdempotencyRecordRow row =
|
||||
rows.findForUpdate(request.scope().digest()).orElseThrow(this::indeterminateClaim);
|
||||
Instant dbNow = rows.databaseNow();
|
||||
if (inserted == 1) {
|
||||
return acquired(row);
|
||||
}
|
||||
@@ -379,28 +174,27 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
Objects.requireNonNull(owner, "owner");
|
||||
Objects.requireNonNull(operationId, "operationId");
|
||||
requirePrimaryWriteTransaction();
|
||||
Row row = findForUpdate(owner.scope().digest()).orElse(null);
|
||||
IdempotencyRecordRow row = rows.findForUpdate(owner.scope().digest()).orElse(null);
|
||||
if (row == null) {
|
||||
return startResult(IdempotencyStartOutcome.ABSENT, null);
|
||||
}
|
||||
if (isDuplicate(row, "START", operationId)) {
|
||||
return startResult(IdempotencyStartOutcome.ALREADY_STARTED_SAME_OPERATION, owner(row));
|
||||
String startDigest = transitionDigest("START", operationId, owner);
|
||||
switch (replayVerdict(row, "START", operationId, startDigest)) {
|
||||
case SAME_ARGUMENTS -> {
|
||||
return startResult(IdempotencyStartOutcome.ALREADY_STARTED_SAME_OPERATION, owner(row));
|
||||
}
|
||||
case DIFFERENT_ARGUMENTS -> {
|
||||
return startResult(IdempotencyStartOutcome.OPERATION_CONFLICT, null);
|
||||
}
|
||||
default -> {
|
||||
// fall through to the first application
|
||||
}
|
||||
}
|
||||
IdempotencyStartOutcome mismatch = classifyStartMismatch(row, owner);
|
||||
if (mismatch != null) {
|
||||
return startResult(mismatch, null);
|
||||
}
|
||||
String resultDigest = transitionDigest("START", operationId, owner);
|
||||
int updated =
|
||||
jdbc.update(
|
||||
START_SQL,
|
||||
operationId.value(),
|
||||
resultDigest,
|
||||
owner.scope().digest(),
|
||||
owner.ownerToken(),
|
||||
owner.attempt(),
|
||||
owner.claimOperationId().value(),
|
||||
owner.stateRevision());
|
||||
int updated = transitions.start(owner, operationId.value(), startDigest);
|
||||
if (updated != 1) {
|
||||
return startResult(IdempotencyStartOutcome.NOT_OWNER, null);
|
||||
}
|
||||
@@ -415,28 +209,31 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
Objects.requireNonNull(operationId, "operationId");
|
||||
requirePositiveBounded("processing lease TTL", processingLeaseTtl, MAXIMUM_PROCESSING_LEASE);
|
||||
requirePrimaryWriteTransaction();
|
||||
Row row = findForUpdate(owner.scope().digest()).orElse(null);
|
||||
IdempotencyRecordRow row = rows.findForUpdate(owner.scope().digest()).orElse(null);
|
||||
if (row == null) {
|
||||
return renewResult(IdempotencyRenewOutcome.ABSENT, null);
|
||||
}
|
||||
if (isDuplicate(row, "RENEW", operationId)) {
|
||||
return renewResult(IdempotencyRenewOutcome.ALREADY_RENEWED_SAME_OPERATION, owner(row));
|
||||
// The lease TTL is part of what a renewal decided, so it is part of the digest. Without it, a
|
||||
// retry asking for a different lease was confirmed as the renewal already applied, and the
|
||||
// caller went on believing it held the record for longer than the row says it does.
|
||||
String renewDigest =
|
||||
transitionDigest("RENEW", operationId, owner, Long.toString(processingLeaseTtl.toMillis()));
|
||||
switch (replayVerdict(row, "RENEW", operationId, renewDigest)) {
|
||||
case SAME_ARGUMENTS -> {
|
||||
return renewResult(IdempotencyRenewOutcome.ALREADY_RENEWED_SAME_OPERATION, owner(row));
|
||||
}
|
||||
case DIFFERENT_ARGUMENTS -> {
|
||||
return renewResult(IdempotencyRenewOutcome.OPERATION_CONFLICT, null);
|
||||
}
|
||||
default -> {
|
||||
// fall through to the first application
|
||||
}
|
||||
}
|
||||
IdempotencyRenewOutcome mismatch = classifyRenewMismatch(row, owner);
|
||||
if (mismatch != null) {
|
||||
return renewResult(mismatch, null);
|
||||
}
|
||||
int updated =
|
||||
jdbc.update(
|
||||
RENEW_SQL,
|
||||
processingLeaseTtl.toMillis(),
|
||||
operationId.value(),
|
||||
transitionDigest("RENEW", operationId, owner),
|
||||
owner.scope().digest(),
|
||||
owner.ownerToken(),
|
||||
owner.attempt(),
|
||||
owner.claimOperationId().value(),
|
||||
owner.stateRevision());
|
||||
int updated = transitions.renew(owner, processingLeaseTtl, operationId.value(), renewDigest);
|
||||
if (updated != 1) {
|
||||
return renewResult(IdempotencyRenewOutcome.NOT_OWNER, null);
|
||||
}
|
||||
@@ -456,7 +253,7 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
requirePositiveBounded("replay TTL", replayTtl, MAXIMUM_RETENTION);
|
||||
requireInlineResponse(response);
|
||||
requirePrimaryWriteTransaction();
|
||||
Row row = findForUpdate(owner.scope().digest()).orElse(null);
|
||||
IdempotencyRecordRow row = rows.findForUpdate(owner.scope().digest()).orElse(null);
|
||||
if (row == null) {
|
||||
return IdempotencyCompleteOutcome.ABSENT;
|
||||
}
|
||||
@@ -473,8 +270,8 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
return mismatch;
|
||||
}
|
||||
int updated =
|
||||
jdbc.update(
|
||||
COMPLETE_SQL,
|
||||
transitions.complete(
|
||||
owner,
|
||||
operationId.value(),
|
||||
// The transition digest, not the response digest. Reusing the response digest here made
|
||||
// two completions of different operations with identical payloads indistinguishable,
|
||||
@@ -487,13 +284,7 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
Long.toString(replayTtl.toMillis())),
|
||||
response.payload(),
|
||||
responseDigest,
|
||||
replayTtl.toMillis(),
|
||||
replayTtl.toMillis(),
|
||||
owner.scope().digest(),
|
||||
owner.ownerToken(),
|
||||
owner.attempt(),
|
||||
owner.claimOperationId().value(),
|
||||
owner.stateRevision());
|
||||
replayTtl);
|
||||
return updated == 1
|
||||
? IdempotencyCompleteOutcome.COMPLETED
|
||||
: IdempotencyCompleteOutcome.INDETERMINATE;
|
||||
@@ -510,7 +301,7 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
Objects.requireNonNull(operationId, "operationId");
|
||||
requirePositiveBounded("failure retention", retention, MAXIMUM_RETENTION);
|
||||
requirePrimaryWriteTransaction();
|
||||
Row row = findForUpdate(owner.scope().digest()).orElse(null);
|
||||
IdempotencyRecordRow row = rows.findForUpdate(owner.scope().digest()).orElse(null);
|
||||
if (row == null) {
|
||||
return IdempotencyFailOutcome.ABSENT;
|
||||
}
|
||||
@@ -518,8 +309,23 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
disposition == IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE
|
||||
? "FAIL_RETRYABLE"
|
||||
: "FAIL_ABANDONED";
|
||||
if (isDuplicate(row, transitionKind, operationId)) {
|
||||
return IdempotencyFailOutcome.ALREADY_MARKED_SAME_OPERATION;
|
||||
String failDigest =
|
||||
transitionDigest(
|
||||
transitionKind,
|
||||
operationId,
|
||||
owner,
|
||||
disposition.name(),
|
||||
Long.toString(retention.toMillis()));
|
||||
switch (replayVerdict(row, transitionKind, operationId, failDigest)) {
|
||||
case SAME_ARGUMENTS -> {
|
||||
return IdempotencyFailOutcome.ALREADY_MARKED_SAME_OPERATION;
|
||||
}
|
||||
case DIFFERENT_ARGUMENTS -> {
|
||||
return IdempotencyFailOutcome.OPERATION_CONFLICT;
|
||||
}
|
||||
default -> {
|
||||
// fall through to the first application
|
||||
}
|
||||
}
|
||||
IdempotencyFailOutcome mismatch = classifyFailMismatch(row, owner);
|
||||
if (mismatch != null) {
|
||||
@@ -530,24 +336,14 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
? IdempotencyState.FAILED_RETRYABLE.name()
|
||||
: IdempotencyState.ABANDONED.name();
|
||||
int updated =
|
||||
jdbc.update(
|
||||
FAIL_SQL,
|
||||
transitions.fail(
|
||||
owner,
|
||||
targetState,
|
||||
operationId.value(),
|
||||
transitionKind,
|
||||
transitionDigest(
|
||||
transitionKind,
|
||||
operationId,
|
||||
owner,
|
||||
disposition.name(),
|
||||
Long.toString(retention.toMillis())),
|
||||
failDigest,
|
||||
disposition.name(),
|
||||
retention.toMillis(),
|
||||
owner.scope().digest(),
|
||||
owner.ownerToken(),
|
||||
owner.attempt(),
|
||||
owner.claimOperationId().value(),
|
||||
owner.stateRevision());
|
||||
retention);
|
||||
if (updated != 1) {
|
||||
return IdempotencyFailOutcome.INDETERMINATE;
|
||||
}
|
||||
@@ -562,12 +358,21 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
Objects.requireNonNull(owner, "owner");
|
||||
Objects.requireNonNull(operationId, "operationId");
|
||||
requirePrimaryWriteTransaction();
|
||||
Row row = findForUpdate(owner.scope().digest()).orElse(null);
|
||||
IdempotencyRecordRow row = rows.findForUpdate(owner.scope().digest()).orElse(null);
|
||||
if (row == null) {
|
||||
return IdempotencyReleaseOutcome.ABSENT;
|
||||
}
|
||||
if (isDuplicate(row, "RELEASE", operationId)) {
|
||||
return IdempotencyReleaseOutcome.ALREADY_RELEASED_SAME_OPERATION;
|
||||
String releaseDigest = transitionDigest("RELEASE", operationId, owner);
|
||||
switch (replayVerdict(row, "RELEASE", operationId, releaseDigest)) {
|
||||
case SAME_ARGUMENTS -> {
|
||||
return IdempotencyReleaseOutcome.ALREADY_RELEASED_SAME_OPERATION;
|
||||
}
|
||||
case DIFFERENT_ARGUMENTS -> {
|
||||
return IdempotencyReleaseOutcome.OPERATION_CONFLICT;
|
||||
}
|
||||
default -> {
|
||||
// fall through to the first application
|
||||
}
|
||||
}
|
||||
if (!sameOwnerTuple(row, owner)) {
|
||||
return IdempotencyReleaseOutcome.NOT_OWNER;
|
||||
@@ -578,16 +383,7 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
if (row.state() != IdempotencyState.CLAIMED) {
|
||||
return IdempotencyReleaseOutcome.OPERATION_CONFLICT;
|
||||
}
|
||||
int updated =
|
||||
jdbc.update(
|
||||
RELEASE_SQL,
|
||||
operationId.value(),
|
||||
transitionDigest("RELEASE", operationId, owner),
|
||||
owner.scope().digest(),
|
||||
owner.ownerToken(),
|
||||
owner.attempt(),
|
||||
owner.claimOperationId().value(),
|
||||
owner.stateRevision());
|
||||
int updated = transitions.release(owner, operationId.value(), releaseDigest);
|
||||
return updated == 1
|
||||
? IdempotencyReleaseOutcome.RELEASED_BEFORE_EXECUTION
|
||||
: IdempotencyReleaseOutcome.INDETERMINATE;
|
||||
@@ -596,11 +392,11 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
@Override
|
||||
public IdempotencyInspection inspect(IdempotencyInspectionRequest request) {
|
||||
Objects.requireNonNull(request, "request");
|
||||
Optional<Row> found = find(request.scope().digest());
|
||||
Optional<IdempotencyRecordRow> found = rows.find(request.scope().digest());
|
||||
if (found.isEmpty()) {
|
||||
return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.ABSENT);
|
||||
}
|
||||
Row row = found.get();
|
||||
IdempotencyRecordRow row = found.get();
|
||||
if (!row.requestHash().equals(request.requestFingerprint().hex())) {
|
||||
return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.FINGERPRINT_MISMATCH);
|
||||
}
|
||||
@@ -630,48 +426,25 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
};
|
||||
}
|
||||
|
||||
private IdempotencyClaimOutcome resetClaim(IdempotencyClaimRequest request, Row row) {
|
||||
int updated =
|
||||
jdbc.update(
|
||||
RESET_CLAIM_SQL,
|
||||
request.scope().digest(),
|
||||
request.scope().operationCode(),
|
||||
request.scope().keyDigestVersion(),
|
||||
request.scope().operationCode(),
|
||||
request.requestFingerprint().hex(),
|
||||
request.claimAttempt().ownerToken(),
|
||||
request.claimAttempt().operationId().value(),
|
||||
request.processingLeaseTtl().toMillis(),
|
||||
request.policyRevision(),
|
||||
request.responseCodecId(),
|
||||
request.replayTtl().toMillis(),
|
||||
request.scope().digest(),
|
||||
row.state().name(),
|
||||
row.stateRevision());
|
||||
if (updated != 1) {
|
||||
private IdempotencyClaimOutcome resetClaim(
|
||||
IdempotencyClaimRequest request, IdempotencyRecordRow row) {
|
||||
if (claims.reset(request, row) != 1) {
|
||||
return new IdempotencyClaimOutcome.Indeterminate(request.claimAttempt().operationId());
|
||||
}
|
||||
Row reset = findForUpdate(request.scope().digest()).orElseThrow(this::indeterminateClaim);
|
||||
IdempotencyRecordRow reset =
|
||||
rows.findForUpdate(request.scope().digest()).orElseThrow(this::indeterminateClaim);
|
||||
return new IdempotencyClaimOutcome.TakenOverClaimed(owner(reset), reset.processingLeaseUntil());
|
||||
}
|
||||
|
||||
private void abandonExpiredExecution(Row row) {
|
||||
private void abandonExpiredExecution(IdempotencyRecordRow row) {
|
||||
String resultDigest = sha256("EXPIRED_EXECUTION|" + row.claimOperationId());
|
||||
int updated =
|
||||
jdbc.update(
|
||||
ABANDON_EXPIRED_EXECUTION_SQL,
|
||||
resultDigest,
|
||||
row.scopeHash(),
|
||||
row.ownerToken(),
|
||||
row.attempt(),
|
||||
row.claimOperationId(),
|
||||
row.stateRevision());
|
||||
if (updated != 1) {
|
||||
if (claims.abandonExpiredExecution(row, resultDigest) != 1) {
|
||||
throw indeterminateClaim();
|
||||
}
|
||||
}
|
||||
|
||||
private IdempotencyStartOutcome classifyStartMismatch(Row row, IdempotencyOwner owner) {
|
||||
private IdempotencyStartOutcome classifyStartMismatch(
|
||||
IdempotencyRecordRow row, IdempotencyOwner owner) {
|
||||
if (!sameOwnerIdentity(row, owner)) {
|
||||
return IdempotencyStartOutcome.NOT_OWNER;
|
||||
}
|
||||
@@ -684,7 +457,8 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
return null;
|
||||
}
|
||||
|
||||
private IdempotencyRenewOutcome classifyRenewMismatch(Row row, IdempotencyOwner owner) {
|
||||
private IdempotencyRenewOutcome classifyRenewMismatch(
|
||||
IdempotencyRecordRow row, IdempotencyOwner owner) {
|
||||
if (!sameOwnerIdentity(row, owner)) {
|
||||
return IdempotencyRenewOutcome.NOT_OWNER;
|
||||
}
|
||||
@@ -697,7 +471,8 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
return null;
|
||||
}
|
||||
|
||||
private IdempotencyCompleteOutcome classifyCompleteMismatch(Row row, IdempotencyOwner owner) {
|
||||
private IdempotencyCompleteOutcome classifyCompleteMismatch(
|
||||
IdempotencyRecordRow row, IdempotencyOwner owner) {
|
||||
if (!sameOwnerIdentity(row, owner)) {
|
||||
return IdempotencyCompleteOutcome.NOT_OWNER;
|
||||
}
|
||||
@@ -710,7 +485,8 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
return null;
|
||||
}
|
||||
|
||||
private IdempotencyFailOutcome classifyFailMismatch(Row row, IdempotencyOwner owner) {
|
||||
private IdempotencyFailOutcome classifyFailMismatch(
|
||||
IdempotencyRecordRow row, IdempotencyOwner owner) {
|
||||
if (!sameOwnerIdentity(row, owner)) {
|
||||
return IdempotencyFailOutcome.NOT_OWNER;
|
||||
}
|
||||
@@ -723,53 +499,6 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
return null;
|
||||
}
|
||||
|
||||
private Optional<Row> findForUpdate(String scopeHash) {
|
||||
return queryOne(SELECT_ROW_FOR_UPDATE_SQL, scopeHash);
|
||||
}
|
||||
|
||||
private Optional<Row> find(String scopeHash) {
|
||||
return queryOne(SELECT_ROW_SQL, scopeHash);
|
||||
}
|
||||
|
||||
private Optional<Row> queryOne(String sql, String scopeHash) {
|
||||
List<Row> rows = jdbc.query(sql, this::mapRow, scopeHash);
|
||||
if (rows.size() > 1) {
|
||||
throw new IllegalStateException("multiple idempotency V2 rows for one scope digest");
|
||||
}
|
||||
return rows.stream().findFirst();
|
||||
}
|
||||
|
||||
private Row mapRow(ResultSet resultSet, int rowNumber) throws SQLException {
|
||||
return new Row(
|
||||
resultSet.getString("scope_hash"),
|
||||
resultSet.getInt("key_digest_version"),
|
||||
resultSet.getString("operation_code"),
|
||||
resultSet.getString("request_hash"),
|
||||
IdempotencyState.valueOf(resultSet.getString("status")),
|
||||
resultSet.getLong("state_revision"),
|
||||
resultSet.getString("owner_token"),
|
||||
resultSet.getLong("attempt"),
|
||||
resultSet.getString("claim_operation_id"),
|
||||
resultSet.getString("last_transition_operation_id"),
|
||||
resultSet.getString("last_transition_kind"),
|
||||
resultSet.getString("last_transition_result_digest"),
|
||||
instant(resultSet, "processing_lease_until"),
|
||||
nullableInstant(resultSet, "replay_until"),
|
||||
resultSet.getString("response_payload"),
|
||||
resultSet.getString("response_digest"),
|
||||
resultSet.getString("response_codec_id"),
|
||||
resultSet.getInt("policy_revision"),
|
||||
instant(resultSet, "expires_at"));
|
||||
}
|
||||
|
||||
private Instant databaseNowAfterLock() {
|
||||
OffsetDateTime value = jdbc.queryForObject(DB_NOW_SQL, OffsetDateTime.class);
|
||||
if (value == null) {
|
||||
throw new IllegalStateException("PostgreSQL returned no authoritative database time");
|
||||
}
|
||||
return value.toInstant();
|
||||
}
|
||||
|
||||
private void requireActiveCapability() {
|
||||
guard.requireActiveCapability();
|
||||
}
|
||||
@@ -793,41 +522,80 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean sameClaimAttempt(Row row, IdempotencyClaimAttempt attempt) {
|
||||
private static boolean sameClaimAttempt(
|
||||
IdempotencyRecordRow row, IdempotencyClaimAttempt attempt) {
|
||||
return row.ownerToken().equals(attempt.ownerToken())
|
||||
&& row.claimOperationId().equals(attempt.operationId().value());
|
||||
}
|
||||
|
||||
private static boolean sameOwnerIdentity(Row row, IdempotencyOwner owner) {
|
||||
private static boolean sameOwnerIdentity(IdempotencyRecordRow row, IdempotencyOwner owner) {
|
||||
return row.scopeHash().equals(owner.scope().digest())
|
||||
&& row.ownerToken().equals(owner.ownerToken())
|
||||
&& row.attempt() == owner.attempt()
|
||||
&& row.claimOperationId().equals(owner.claimOperationId().value());
|
||||
}
|
||||
|
||||
private static boolean sameOwnerTuple(Row row, IdempotencyOwner owner) {
|
||||
private static boolean sameOwnerTuple(IdempotencyRecordRow row, IdempotencyOwner owner) {
|
||||
return sameOwnerIdentity(row, owner) && row.stateRevision() == owner.stateRevision();
|
||||
}
|
||||
|
||||
private static boolean isDuplicate(Row row, String transitionKind, OperationId operationId) {
|
||||
return transitionKind.equals(row.lastTransitionKind())
|
||||
&& operationId.value().equals(row.lastTransitionOperationId());
|
||||
/**
|
||||
* Whether the recorded transition is this call again, and whether it carried these arguments.
|
||||
*
|
||||
* <p>The kind and the operation id were the whole test, and the digest the transition wrote was
|
||||
* read by nothing. So a second {@code markFailed} under one operation id was answered {@code
|
||||
* ALREADY_MARKED_SAME_OPERATION} whatever it asked for: a retry that changed the disposition from
|
||||
* retryable to abandoned, or changed the retention, was reported as the already-applied
|
||||
* transition and silently did not happen. The caller's evidence then said the record was
|
||||
* abandoned while the row said retryable.
|
||||
*
|
||||
* <p>Recomputing the digest from the caller's own arguments answers it. A genuine retry presents
|
||||
* the owner handle it presented the first time, so the revision, the owner and every semantic
|
||||
* argument reproduce the stored digest exactly; anything else is a different transition wearing
|
||||
* the same operation id, and belongs in a conflict rather than in a confirmation.
|
||||
*/
|
||||
private static ReplayVerdict replayVerdict(
|
||||
IdempotencyRecordRow row,
|
||||
String transitionKind,
|
||||
OperationId operationId,
|
||||
String expectedDigest) {
|
||||
if (!transitionKind.equals(row.lastTransitionKind())
|
||||
|| !operationId.value().equals(row.lastTransitionOperationId())) {
|
||||
return ReplayVerdict.NOT_A_REPLAY;
|
||||
}
|
||||
return expectedDigest.equals(row.lastTransitionResultDigest())
|
||||
? ReplayVerdict.SAME_ARGUMENTS
|
||||
: ReplayVerdict.DIFFERENT_ARGUMENTS;
|
||||
}
|
||||
|
||||
private static boolean isExpiredCompleted(Row row, Instant dbNow) {
|
||||
/** What a recorded transition under the caller's operation id turned out to be. */
|
||||
private enum ReplayVerdict {
|
||||
|
||||
/** No transition of this kind is recorded, so the caller is asking for a first application. */
|
||||
NOT_A_REPLAY,
|
||||
|
||||
/** The recorded transition is this one: confirm it rather than applying it twice. */
|
||||
SAME_ARGUMENTS,
|
||||
|
||||
/**
|
||||
* A transition of this kind is recorded under different arguments: a conflict, not a replay.
|
||||
*/
|
||||
DIFFERENT_ARGUMENTS
|
||||
}
|
||||
|
||||
private static boolean isExpiredCompleted(IdempotencyRecordRow row, Instant dbNow) {
|
||||
return row.state() == IdempotencyState.COMPLETED
|
||||
&& row.replayUntil() != null
|
||||
&& !dbNow.isBefore(row.replayUntil());
|
||||
}
|
||||
|
||||
private static IdempotencyClaimOutcome.Acquired acquired(Row row) {
|
||||
private static IdempotencyClaimOutcome.Acquired acquired(IdempotencyRecordRow row) {
|
||||
return new IdempotencyClaimOutcome.Acquired(owner(row), row.processingLeaseUntil());
|
||||
}
|
||||
|
||||
private static IdempotencyOwner owner(Row row) {
|
||||
private static IdempotencyOwner owner(IdempotencyRecordRow row) {
|
||||
return new IdempotencyOwner(
|
||||
new dev.caskeleton.application.idempotency.v2.IdempotencyScopeDigest(
|
||||
row.scopeHash(), row.keyDigestVersion(), row.operationCode()),
|
||||
new IdempotencyScopeDigest(row.scopeHash(), row.keyDigestVersion(), row.operationCode()),
|
||||
row.ownerToken(),
|
||||
row.attempt(),
|
||||
row.stateRevision(),
|
||||
@@ -845,7 +613,7 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
}
|
||||
|
||||
private static IdempotencyInspection inspectionWithOwner(
|
||||
IdempotencyInspectionOutcome outcome, Row row) {
|
||||
IdempotencyInspectionOutcome outcome, IdempotencyRecordRow row) {
|
||||
return new IdempotencyInspection(
|
||||
outcome,
|
||||
Optional.of(owner(row)),
|
||||
@@ -888,37 +656,7 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
|
||||
}
|
||||
}
|
||||
|
||||
private static Instant instant(ResultSet resultSet, String column) throws SQLException {
|
||||
return resultSet.getObject(column, OffsetDateTime.class).toInstant();
|
||||
}
|
||||
|
||||
private static Instant nullableInstant(ResultSet resultSet, String column) throws SQLException {
|
||||
OffsetDateTime value = resultSet.getObject(column, OffsetDateTime.class);
|
||||
return value == null ? null : value.toInstant();
|
||||
}
|
||||
|
||||
private IllegalStateException indeterminateClaim() {
|
||||
return new IllegalStateException("owner-safe idempotency claim outcome is indeterminate");
|
||||
}
|
||||
|
||||
private record Row(
|
||||
String scopeHash,
|
||||
int keyDigestVersion,
|
||||
String operationCode,
|
||||
String requestHash,
|
||||
IdempotencyState state,
|
||||
long stateRevision,
|
||||
String ownerToken,
|
||||
long attempt,
|
||||
String claimOperationId,
|
||||
String lastTransitionOperationId,
|
||||
String lastTransitionKind,
|
||||
String lastTransitionResultDigest,
|
||||
Instant processingLeaseUntil,
|
||||
Instant replayUntil,
|
||||
String responsePayload,
|
||||
String responseDigest,
|
||||
String responseCodecId,
|
||||
int policyRevision,
|
||||
Instant expiresAt) {}
|
||||
}
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
-- A terminal upload state, so a cancelled upload and a live one stop looking alike.
|
||||
--
|
||||
-- Cleanup read the writer lease, found none, and then deleted the staging bytes. Between the read
|
||||
-- and the delete a writer can acquire that very lease — nothing in the database said the upload was
|
||||
-- finished with — and the object cleanup removed was one an upload was actively appending to. The
|
||||
-- writer's own acquire statement checked the upload's expiry and its lease and nothing about
|
||||
-- whether the upload had been cancelled or had failed verification, so it granted the lease
|
||||
-- happily.
|
||||
--
|
||||
-- The state is what both sides now agree on. Cancel and failed finalize move the session to
|
||||
-- TERMINAL inside the transaction that queues the cleanup; acquire, renew and offset commit require
|
||||
-- ACTIVE; and cleanup claims the row with a conditional update rather than deciding from a value it
|
||||
-- read a moment earlier. A writer that arrives after the terminalize is refused, and a cleanup that
|
||||
-- arrives while a lease is genuinely held claims nothing and defers.
|
||||
--
|
||||
-- Defaulted to ACTIVE, so every session that predates this column keeps behaving exactly as it did.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM capability_schema_registry
|
||||
WHERE capability_id = 'jpa-fileserver-metadata-v1'
|
||||
AND feature_revision >= 2
|
||||
) THEN
|
||||
RAISE EXCEPTION 'fileserver upload terminal state requires fileserver metadata revision 2';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
ALTER TABLE fs_upload_session
|
||||
ADD COLUMN IF NOT EXISTS lifecycle_state varchar(16) NOT NULL DEFAULT 'ACTIVE';
|
||||
|
||||
ALTER TABLE fs_upload_session
|
||||
DROP CONSTRAINT IF EXISTS ck_fs_upload_lifecycle_state;
|
||||
|
||||
ALTER TABLE fs_upload_session
|
||||
ADD CONSTRAINT ck_fs_upload_lifecycle_state
|
||||
CHECK (lifecycle_state IN ('ACTIVE', 'TERMINAL'));
|
||||
|
||||
COMMENT ON COLUMN fs_upload_session.lifecycle_state IS
|
||||
'ACTIVE while the upload may still be written to. Cancel and failed finalize set TERMINAL in '
|
||||
'the same transaction that queues the staging cleanup, and the writer statements refuse a '
|
||||
'TERMINAL session, so cleanup cannot race a writer for the same bytes.';
|
||||
|
||||
-- Cleanup's claim: terminal sessions whose lease has lapsed.
|
||||
CREATE INDEX IF NOT EXISTS ix_fs_upload_session_terminal
|
||||
ON fs_upload_session (lease_until)
|
||||
WHERE lifecycle_state = 'TERMINAL';
|
||||
+109
@@ -9,9 +9,12 @@ import dev.caskeleton.application.idempotency.v2.IdempotencyClaimAttempt;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimOutcome;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimRequest;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyCompleteOutcome;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyFailOutcome;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyFailureDisposition;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyInspectionOutcome;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyInspectionRequest;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyOwner;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyRenewOutcome;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyScopeDigest;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyStartOutcome;
|
||||
import dev.caskeleton.application.transaction.OperationId;
|
||||
@@ -249,11 +252,117 @@ class PostgreSqlIdempotencyIntegrationTest {
|
||||
.isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRetriedFailureWithTheSameArgumentsIsConfirmedRatherThanAppliedTwice() {
|
||||
IdempotencyOwner executing = claimAndStart("claim-fail-replay", "start-fail-replay");
|
||||
|
||||
IdempotencyFailOutcome first =
|
||||
transactions.execute(
|
||||
ignored ->
|
||||
store.markFailed(
|
||||
executing,
|
||||
IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE,
|
||||
Duration.ofHours(1),
|
||||
new OperationId("fail-1")));
|
||||
IdempotencyFailOutcome retry =
|
||||
transactions.execute(
|
||||
ignored ->
|
||||
store.markFailed(
|
||||
executing,
|
||||
IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE,
|
||||
Duration.ofHours(1),
|
||||
new OperationId("fail-1")));
|
||||
|
||||
assertThat(first).isEqualTo(IdempotencyFailOutcome.MARKED_RETRYABLE);
|
||||
assertThat(retry).isEqualTo(IdempotencyFailOutcome.ALREADY_MARKED_SAME_OPERATION);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailureRepeatedUnderOneOperationIdWithDifferentRetentionIsAConflict() {
|
||||
IdempotencyOwner executing = claimAndStart("claim-fail-conflict", "start-fail-conflict");
|
||||
|
||||
transactions.execute(
|
||||
ignored ->
|
||||
store.markFailed(
|
||||
executing,
|
||||
IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE,
|
||||
Duration.ofHours(1),
|
||||
new OperationId("fail-2")));
|
||||
IdempotencyFailOutcome different =
|
||||
transactions.execute(
|
||||
ignored ->
|
||||
store.markFailed(
|
||||
executing,
|
||||
IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE,
|
||||
Duration.ofHours(9),
|
||||
new OperationId("fail-2")));
|
||||
|
||||
assertThat(different)
|
||||
.as(
|
||||
"the recorded failure kept the record for an hour; reporting this call as the one"
|
||||
+ " already applied would tell the caller it got nine")
|
||||
.isEqualTo(IdempotencyFailOutcome.OPERATION_CONFLICT);
|
||||
assertThat(
|
||||
jdbc.queryForObject(
|
||||
"select extract(epoch from (expires_at - updated_at))::bigint "
|
||||
+ "from idempotency_record where scope_hash = ?",
|
||||
Long.class,
|
||||
SCOPE.digest()))
|
||||
.isCloseTo(3600L, org.assertj.core.data.Offset.offset(5L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRenewalRepeatedUnderOneOperationIdWithADifferentLeaseIsAConflict() {
|
||||
IdempotencyOwner claimed = claim("claim-renew-conflict");
|
||||
|
||||
IdempotencyRenewOutcome first =
|
||||
transactions
|
||||
.execute(
|
||||
ignored -> store.renew(claimed, Duration.ofSeconds(30), new OperationId("renew-1")))
|
||||
.outcome();
|
||||
IdempotencyRenewOutcome same =
|
||||
transactions
|
||||
.execute(
|
||||
ignored -> store.renew(claimed, Duration.ofSeconds(30), new OperationId("renew-1")))
|
||||
.outcome();
|
||||
IdempotencyRenewOutcome different =
|
||||
transactions
|
||||
.execute(
|
||||
ignored -> store.renew(claimed, Duration.ofMinutes(45), new OperationId("renew-1")))
|
||||
.outcome();
|
||||
|
||||
assertThat(first).isEqualTo(IdempotencyRenewOutcome.RENEWED);
|
||||
assertThat(same).isEqualTo(IdempotencyRenewOutcome.ALREADY_RENEWED_SAME_OPERATION);
|
||||
assertThat(different)
|
||||
.as(
|
||||
"a renewal that granted thirty seconds must not answer for one asking forty-five minutes")
|
||||
.isEqualTo(IdempotencyRenewOutcome.OPERATION_CONFLICT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void optionalStreamLifecycleIsNonDestructiveAndRecoversInterruptedMigration() throws Exception {
|
||||
PostgreSqlOptionalStreamLifecycle.verify(PostgreSqlOptionalStreamLifecycle.idempotency());
|
||||
}
|
||||
|
||||
private static IdempotencyOwner claim(String claimOperation) {
|
||||
return transactions.execute(
|
||||
ignored ->
|
||||
((IdempotencyClaimOutcome.Acquired)
|
||||
store.claim(
|
||||
request(
|
||||
store.newClaimAttempt(new OperationId(claimOperation)),
|
||||
Duration.ofSeconds(30))))
|
||||
.owner());
|
||||
}
|
||||
|
||||
private static IdempotencyOwner claimAndStart(String claimOperation, String startOperation) {
|
||||
IdempotencyOwner claimed = claim(claimOperation);
|
||||
return transactions
|
||||
.execute(ignored -> store.markExecutionStarted(claimed, new OperationId(startOperation)))
|
||||
.owner()
|
||||
.orElseThrow();
|
||||
}
|
||||
|
||||
private static IdempotencyClaimRequest request(
|
||||
IdempotencyClaimAttempt attempt, Duration processingLease) {
|
||||
return new IdempotencyClaimRequest(
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.readiness;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
/**
|
||||
* The loser of a deduplication race has to be able to read the winner.
|
||||
*
|
||||
* <p>The same defect as the idempotency-key race, in the store next to it, left behind when that
|
||||
* one was fixed. `DeduplicationClaims.insertOrFind` inserted and caught the unique violation, then
|
||||
* read the winner — and PostgreSQL leaves a transaction aborted after a statement-level constraint
|
||||
* violation, so the read is refused until rollback. The loser therefore never learned which
|
||||
* notification already owned the window; it saw SQLSTATE 25P02 instead, and "the duplicate
|
||||
* converges on the original" was unreachable on the one path where two callers actually collide.
|
||||
*
|
||||
* <p>Against a real server, because the defect is entirely what PostgreSQL does to a transaction
|
||||
* after a constraint violation. No fake reproduces it: an in-memory map simply returns the existing
|
||||
* entry, which is why the unit tests for deduplication were green throughout.
|
||||
*/
|
||||
@Tag("jpa-contract")
|
||||
class PostgreSqlNotificationDedupRaceIntegrationTest {
|
||||
|
||||
private static final String CLAIM =
|
||||
"INSERT INTO notification_deduplication_claim ("
|
||||
+ " id, tenant_id, recipient_ref, category, dedup_key, window_bucket,"
|
||||
+ " notification_id, created_at"
|
||||
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, now())"
|
||||
+ " ON CONFLICT (tenant_id, recipient_ref, category, dedup_key, window_bucket)"
|
||||
+ " DO NOTHING";
|
||||
|
||||
@Test
|
||||
@DisplayName("the losing claim reads the winner in a transaction that is still usable")
|
||||
void theLoserReadsTheWinner() throws Exception {
|
||||
try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) {
|
||||
migrate(database);
|
||||
JdbcTemplate jdbc = new JdbcTemplate(database.dataSource());
|
||||
|
||||
UUID winner = UUID.randomUUID();
|
||||
assertThat(claim(jdbc, winner)).isEqualTo(1);
|
||||
|
||||
// The losing caller, in one transaction: claim, get zero rows, then read the winner. The read
|
||||
// is the statement the old catch-block version could never reach.
|
||||
UUID loser = UUID.randomUUID();
|
||||
assertThat(claim(jdbc, loser)).isZero();
|
||||
|
||||
String owner =
|
||||
jdbc.queryForObject(
|
||||
"select notification_id::text from notification_deduplication_claim"
|
||||
+ " where tenant_id = 'tenant-1' and dedup_key = 'dedup-1'",
|
||||
String.class);
|
||||
assertThat(owner)
|
||||
.as("the loser converges on the notification that claimed the window")
|
||||
.isEqualTo(winner.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a different window is a different claim, so the conflict target is the right one")
|
||||
void aDifferentWindowIsNotADuplicate() {
|
||||
// Without this, the assertion above is satisfied by a conflict target so broad that every claim
|
||||
// collides — which would deduplicate notifications that are not duplicates at all.
|
||||
try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) {
|
||||
migrate(database);
|
||||
JdbcTemplate jdbc = new JdbcTemplate(database.dataSource());
|
||||
|
||||
assertThat(claim(jdbc, UUID.randomUUID(), 1L)).isEqualTo(1);
|
||||
assertThat(claim(jdbc, UUID.randomUUID(), 2L)).isEqualTo(1);
|
||||
assertThat(
|
||||
jdbc.queryForObject(
|
||||
"select count(*) from notification_deduplication_claim", Integer.class))
|
||||
.isEqualTo(2);
|
||||
}
|
||||
}
|
||||
|
||||
private static int claim(JdbcTemplate jdbc, UUID notificationId) {
|
||||
return claim(jdbc, notificationId, 1L);
|
||||
}
|
||||
|
||||
private static int claim(JdbcTemplate jdbc, UUID notificationId, long windowBucket) {
|
||||
return jdbc.update(
|
||||
CLAIM,
|
||||
UUID.randomUUID(),
|
||||
"tenant-1",
|
||||
"recipient-1",
|
||||
"transactional",
|
||||
"dedup-1",
|
||||
windowBucket,
|
||||
notificationId);
|
||||
}
|
||||
|
||||
private static void migrate(PostgreSqlReadinessSupport database) {
|
||||
Flyway core =
|
||||
Flyway.configure()
|
||||
.dataSource(database.dataSource())
|
||||
.locations("classpath:db/migration/jpa/core")
|
||||
.table("flyway_jpa_core_history")
|
||||
.baselineVersion("0")
|
||||
.baselineDescription("core baseline")
|
||||
.load();
|
||||
core.baseline();
|
||||
core.migrate();
|
||||
new JdbcTemplate(database.dataSource())
|
||||
.update(
|
||||
"update capability_schema_registry set lifecycle_state = 'ACTIVE' "
|
||||
+ "where capability_id = ?",
|
||||
"jpa-flyway-migration");
|
||||
Flyway notification =
|
||||
Flyway.configure()
|
||||
.dataSource(database.dataSource())
|
||||
.locations("classpath:db/migration/jpa/notification-platform")
|
||||
.table("flyway_jpa_notification_history")
|
||||
.baselineVersion("0")
|
||||
.baselineDescription("notification baseline")
|
||||
.load();
|
||||
notification.baseline();
|
||||
notification.migrate();
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -39,7 +39,10 @@ final class PostgreSqlOptionalStreamLifecycle {
|
||||
"flyway_jpa_idempotency_history",
|
||||
2,
|
||||
List.of("idempotency_record"),
|
||||
List.of("0", "1"),
|
||||
// V2 widens request_hash from char(64) to varchar. Two streams can create this table and
|
||||
// their relative order is not fixed, so whichever runs second corrects the column; that
|
||||
// correction is a version of this stream and belongs in its applied set.
|
||||
List.of("0", "1", "2"),
|
||||
List.of());
|
||||
}
|
||||
|
||||
@@ -94,7 +97,10 @@ final class PostgreSqlOptionalStreamLifecycle {
|
||||
"fs_quota_reservation",
|
||||
"fs_cleanup_item",
|
||||
"fs_recovery_item"),
|
||||
List.of("0", "1", "2"),
|
||||
// V3 fences the cleanup claim with an owner, a token and a lease expiry; V4 gives an upload
|
||||
// a terminal state, so cleanup and a live writer stop competing over a row that looks
|
||||
// available to both.
|
||||
List.of("0", "1", "2", "3", "4"),
|
||||
List.of());
|
||||
}
|
||||
|
||||
@@ -126,7 +132,16 @@ final class PostgreSqlOptionalStreamLifecycle {
|
||||
// V8 makes the admin operation id a claim rather than a check.
|
||||
// V9 gives each execution-evidence fact its certainty, so a restart can still tell
|
||||
// "proven not sent" from "unknown whether sent".
|
||||
List.of("0", "1", "2", "3", "4", "5", "6", "7", "8", "9"),
|
||||
// V10 guards the at-rest envelope: variables_payload holds a base64 AES-GCM envelope, and a
|
||||
// row still carrying plaintext fails the migration rather than meeting the mapper at
|
||||
// runtime,
|
||||
// where it would look like a decryption bug instead of an un-migrated table.
|
||||
//
|
||||
// This list is the stream's contract, not a note about its length: the lifecycle below
|
||||
// disables, re-enables and interrupts the stream and asserts the applied set is unchanged
|
||||
// each time. So a migration added to the stream belongs here, and the version that landed
|
||||
// without being added is why the lane failed the first time anybody ran it.
|
||||
List.of("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"),
|
||||
List.of());
|
||||
}
|
||||
|
||||
|
||||
+70
@@ -67,6 +67,76 @@ class PostgreSqlRecipientLeaseFencingIntegrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a superseded holder cannot write its outcome over its replacement's job")
|
||||
void aSupersededHolderCannotWriteItsOutcome() throws Exception {
|
||||
// The gap the claim CTE and the fenced renew did not cover. Everything before the provider call
|
||||
// is database work a new holder would redo; the outcome is written *after* it, and that write
|
||||
// used to be findById → mutate → saveAndFlush with no owner or fence. A worker whose lease
|
||||
// expired during the submission came back and described an attempt that was no longer the live
|
||||
// one. The @Version column does not stop it: it detects a concurrent edit, not a superseded
|
||||
// writer, and this worker's read is recent enough to win.
|
||||
try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) {
|
||||
Fixture fixture = Fixture.migrated(database);
|
||||
UUID job = fixture.insertDueJob();
|
||||
|
||||
RecipientLease held = fixture.store("worker-a").claim("worker-a", 10, LEASE).getFirst();
|
||||
fixture.expireLease(job);
|
||||
RecipientLease replacement =
|
||||
fixture.store("worker-b").claim("worker-b", 10, LEASE).getFirst();
|
||||
|
||||
int written =
|
||||
fixture.inTransaction(
|
||||
() ->
|
||||
fixture
|
||||
.repository()
|
||||
.transitionHeldBy(
|
||||
job,
|
||||
held.owner(),
|
||||
held.fence(),
|
||||
"COMPLETED",
|
||||
null,
|
||||
java.time.Instant.now()));
|
||||
|
||||
assertThat(written).as("the superseded worker's completion must match no row").isZero();
|
||||
|
||||
Map<String, Object> row = fixture.readLease(job);
|
||||
assertThat(row.get("lease_owner")).isEqualTo("worker-b");
|
||||
assertThat(((Number) row.get("lease_fence")).longValue()).isEqualTo(replacement.fence());
|
||||
assertThat(row.get("delivery_state"))
|
||||
.as("the live holder's job must not have been completed by the worker that lost it")
|
||||
.isNotEqualTo("COMPLETED");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the holder that still owns the job does write its outcome")
|
||||
void theCurrentHolderWritesItsOutcome() throws Exception {
|
||||
// Without this, the assertion above is satisfied by a statement that matches nothing ever.
|
||||
try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) {
|
||||
Fixture fixture = Fixture.migrated(database);
|
||||
UUID job = fixture.insertDueJob();
|
||||
|
||||
RecipientLease held = fixture.store("worker-a").claim("worker-a", 10, LEASE).getFirst();
|
||||
|
||||
int written =
|
||||
fixture.inTransaction(
|
||||
() ->
|
||||
fixture
|
||||
.repository()
|
||||
.transitionHeldBy(
|
||||
job,
|
||||
held.owner(),
|
||||
held.fence(),
|
||||
"COMPLETED",
|
||||
null,
|
||||
java.time.Instant.now()));
|
||||
|
||||
assertThat(written).isEqualTo(1);
|
||||
assertThat(fixture.readLease(job).get("delivery_state")).isEqualTo("COMPLETED");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a superseded holder cannot release its replacement's lease")
|
||||
void aSupersededHolderCannotReleaseItsReplacementsLease() throws Exception {
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.auditing;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.audit.AuditableEntity;
|
||||
import jakarta.persistence.Column;
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The second audit mechanism stays a candidate, and stays distinguishable from the first.
|
||||
*
|
||||
* <p>Two complete technical-audit mechanisms live in this leaf. They disagree on column names, on
|
||||
* actor length and on when the stamp is captured, and the canonical one is {@link AuditableEntity}
|
||||
* — the base the sample entities extend and the migrations were written for. The other is
|
||||
* implemented, tested and composed by nothing.
|
||||
*
|
||||
* <p>Two things can go wrong quietly. The candidate can acquire a stereotype and start stamping in
|
||||
* every deployment that has this module on the classpath, including the ones whose tables have no
|
||||
* such columns — where the result is a startup failure rather than a feature. Or somebody can
|
||||
* "harmonise" the two by editing one side's column names, at which point the schema a deployed
|
||||
* table was migrated for and the schema the entity expects diverge with no migration in between.
|
||||
* Both are silent, so both are asserted.
|
||||
*/
|
||||
class AuditingCandidateStatusTest {
|
||||
|
||||
private static final List<Class<? extends Annotation>> COMPOSING_STEREOTYPES =
|
||||
List.of(
|
||||
Configuration.class, Component.class, org.springframework.stereotype.Repository.class);
|
||||
|
||||
@Test
|
||||
@DisplayName("the candidate wiring carries no stereotype, so a scan cannot compose it")
|
||||
void theCandidateWiringCarriesNoStereotype() {
|
||||
for (Class<? extends Annotation> stereotype : COMPOSING_STEREOTYPES) {
|
||||
assertThat(JpaAuditingConfiguration.class.getAnnotation(stereotype))
|
||||
.as(
|
||||
"@%s here would stamp audit columns in every deployment that merely has this module",
|
||||
stereotype.getSimpleName())
|
||||
.isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the two mechanisms keep different modification columns, which is why one is chosen")
|
||||
void theTwoMechanismsKeepDifferentModificationColumns() {
|
||||
assertThat(columnOf(AuditMetadata.class, "modifiedAt"))
|
||||
.as("the candidate's own column name; changing it silently rewrites a schema contract")
|
||||
.contains("modified_at");
|
||||
assertThat(columnOf(AuditableEntity.class, "updatedAt"))
|
||||
.as("the canonical column name the migrations and the sample entities already use")
|
||||
.contains("updated_at");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the canonical mechanism keeps the wider actor column")
|
||||
void theCanonicalMechanismKeepsTheWiderActorColumn() {
|
||||
assertThat(lengthOf(AuditableEntity.class, "updatedBy")).isEqualTo(256);
|
||||
assertThat(lengthOf(AuditMetadata.class, "modifiedBy"))
|
||||
.as("the two lengths are one of the reasons promotion needs a migration, not a rename")
|
||||
.isEqualTo(64);
|
||||
}
|
||||
|
||||
private static Optional<String> columnOf(Class<?> type, String fieldName) {
|
||||
return field(type, fieldName).map(field -> field.getAnnotation(Column.class).name());
|
||||
}
|
||||
|
||||
private static int lengthOf(Class<?> type, String fieldName) {
|
||||
return field(type, fieldName)
|
||||
.map(field -> field.getAnnotation(Column.class).length())
|
||||
.orElseThrow(() -> new AssertionError(type.getSimpleName() + " has no " + fieldName));
|
||||
}
|
||||
|
||||
private static Optional<Field> field(Class<?> type, String fieldName) {
|
||||
return Arrays.stream(type.getDeclaredFields())
|
||||
.filter(field -> field.getName().equals(fieldName))
|
||||
.findFirst();
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.experimental.replica.ConsistencyAwareDataSourceRouter;
|
||||
import dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsTenantSessionBinder;
|
||||
import dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaTenantMigrationOrchestrator;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The experimental entry points cannot be obtained without stating consent.
|
||||
*
|
||||
* <p>{@code experimental/**} ships inside the main artifact — there is no separate variant — so the
|
||||
* only thing standing between a deployment and tenant isolation, RLS binding or replica routing is
|
||||
* whether the code can be constructed. The gate documents that presence on the classpath is not
|
||||
* consent, and for a while nothing enforced it: a public constructor made one {@code new} a
|
||||
* complete bypass of the flag the gate exists to require.
|
||||
*
|
||||
* <p>So the check is on the constructor, not on the gate's own logic. A gate that fails correctly
|
||||
* when asked is worth nothing if the caller never has to ask, and the gate's unit tests cannot see
|
||||
* that difference because they always ask.
|
||||
*/
|
||||
class ExperimentalEntryConsentTest {
|
||||
|
||||
/** The behaviour-bearing entry points; the surrounding value types are not activations. */
|
||||
private static final List<Class<?>> ENTRY_POINTS =
|
||||
List.of(
|
||||
ConsistencyAwareDataSourceRouter.class,
|
||||
RlsTenantSessionBinder.class,
|
||||
SchemaTenantMigrationOrchestrator.class);
|
||||
|
||||
private final ExperimentalFeatureGate gate = new ExperimentalFeatureGate();
|
||||
|
||||
@Test
|
||||
@DisplayName("no experimental entry point can be constructed from outside its package")
|
||||
void noEntryPointCanBeConstructedFromOutsideItsPackage() {
|
||||
for (Class<?> entryPoint : ENTRY_POINTS) {
|
||||
for (Constructor<?> constructor : entryPoint.getDeclaredConstructors()) {
|
||||
assertThat(Modifier.isPublic(constructor.getModifiers()))
|
||||
.as(
|
||||
"%s has a public constructor, so one `new` bypasses the flag entirely",
|
||||
entryPoint.getSimpleName())
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("every experimental entry point offers a gate-taking factory and nothing else")
|
||||
void everyEntryPointOffersAGateTakingFactory() {
|
||||
for (Class<?> entryPoint : ENTRY_POINTS) {
|
||||
assertThat(entryPoint.getDeclaredMethods())
|
||||
.as("%s must be obtainable, and only through the gate", entryPoint.getSimpleName())
|
||||
.anySatisfy(
|
||||
method -> {
|
||||
assertThat(method.getName()).isEqualTo("enabledBy");
|
||||
assertThat(method.getReturnType()).isEqualTo(entryPoint);
|
||||
assertThat(method.getParameterTypes()[0]).isEqualTo(ExperimentalFeatureGate.class);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the RLS binder refuses to exist when its flag is absent")
|
||||
void theRlsBinderRefusesToExistWithoutItsFlag() {
|
||||
assertThatThrownBy(() -> RlsTenantSessionBinder.enabledBy(gate, Map.of()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining(ExperimentalFeature.MULTITENANCY_RLS.property());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the RLS binder refuses to exist when another feature's flag is the one that is on")
|
||||
void theRlsBinderRefusesAnotherFeaturesFlag() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
RlsTenantSessionBinder.enabledBy(
|
||||
gate, Map.of(ExperimentalFeature.READ_REPLICA.property(), true)))
|
||||
.as("consent is per feature; one experimental flag is not consent to all of them")
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the RLS binder is obtainable once its own flag is explicitly true")
|
||||
void theRlsBinderIsObtainableWithItsOwnFlag() {
|
||||
assertThat(
|
||||
RlsTenantSessionBinder.enabledBy(
|
||||
gate, Map.of(ExperimentalFeature.MULTITENANCY_RLS.property(), true)))
|
||||
.isNotNull();
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.platform;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The pool lane's description matches what the pool lane does.
|
||||
*
|
||||
* <p>It did not. The lane was named for performance, described as certifying pool and {@code
|
||||
* REQUIRES_NEW} pressure, and gated behind {@code performance.assertions.enabled} — which defaulted
|
||||
* to false in the build, in the nightly job that set it explicitly, and therefore in the release
|
||||
* gate that depended on it. A release could report a passed performance certification while no
|
||||
* latency, throughput or pool-wait bound had ever been compared to anything.
|
||||
*
|
||||
* <p>The lane was renamed and the flag removed, but a removed flag leaves its name behind in the
|
||||
* files that used to describe it, and a name in a document is what the next reader believes. So the
|
||||
* check is textual and deliberately blunt: no file that describes this lane may mention the flag
|
||||
* that no longer exists or promise a number the lane does not measure.
|
||||
*/
|
||||
class PoolLaneClaimTest {
|
||||
|
||||
/** The files whose job is to say what this lane is. */
|
||||
private static final List<String> LANE_DESCRIPTIONS =
|
||||
List.of(
|
||||
"src/adapter/outbound/persistence-jpa/build.gradle",
|
||||
"src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/PoolPressureContractTest.java",
|
||||
"src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/HikariPoolSaturationContractTest.java",
|
||||
"src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/RequiresNewPoolPressureContractTest.java",
|
||||
".github/workflows/jpa-nightly.yml",
|
||||
"docs/jpa/repository-adaptation.md");
|
||||
|
||||
/**
|
||||
* The flag nothing reads.
|
||||
*
|
||||
* <p>Split into fragments so this test does not itself become a hit for the search it performs; a
|
||||
* guard that matches its own source is a guard that can never pass.
|
||||
*/
|
||||
private static final String REMOVED_FLAG = "performance." + "assertions." + "enabled";
|
||||
|
||||
@Test
|
||||
@DisplayName("no file describing the pool lane mentions the flag that was removed")
|
||||
void noFileMentionsTheRemovedFlag() {
|
||||
for (String description : LANE_DESCRIPTIONS) {
|
||||
assertThat(read(description))
|
||||
.as("%s still names a property no code reads", description)
|
||||
.doesNotContain(REMOVED_FLAG);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no file describing the pool lane claims a certification or a machine bound")
|
||||
void noFileClaimsACertificationOrAMachineBound() {
|
||||
for (String description : LANE_DESCRIPTIONS) {
|
||||
String text = read(description).toLowerCase(Locale.ROOT);
|
||||
int laneMention = text.indexOf("pool");
|
||||
if (laneMention < 0) {
|
||||
continue;
|
||||
}
|
||||
assertThat(text)
|
||||
.as("%s describes the pool lane as certifying something it does not measure", description)
|
||||
.doesNotContain("pool pressure certification")
|
||||
.doesNotContain("machine-dependent bounds")
|
||||
.doesNotContain("machine bounds");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the nightly job runs the behaviour lane, under its behaviour name")
|
||||
void theNightlyJobRunsTheBehaviourLane() {
|
||||
String nightly = read(".github/workflows/jpa-nightly.yml");
|
||||
|
||||
assertThat(nightly)
|
||||
.as("the lane that runs must be the one the build registers")
|
||||
.contains(":adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest");
|
||||
assertThat(nightly)
|
||||
.as("the old task name would silently select nothing")
|
||||
.doesNotContain(":adapter:outbound:persistence-jpa:jpaPlatformPerformanceTest");
|
||||
}
|
||||
|
||||
/** Reads a repository-relative file, locating the root by walking up. */
|
||||
private static String read(String repositoryRelativePath) {
|
||||
for (Path directory = Path.of("").toAbsolutePath();
|
||||
directory != null;
|
||||
directory = directory.getParent()) {
|
||||
Path candidate = directory.resolve(repositoryRelativePath);
|
||||
if (Files.isRegularFile(candidate)) {
|
||||
try {
|
||||
return Files.readString(candidate, StandardCharsets.UTF_8);
|
||||
} catch (IOException unreadable) {
|
||||
throw new IllegalStateException("cannot read " + candidate, unreadable);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"cannot locate " + repositoryRelativePath + " from " + Path.of("").toAbsolutePath());
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.postgresql;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName;
|
||||
import dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintCode;
|
||||
import dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory;
|
||||
import dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException;
|
||||
import dev.caskeleton.adapter.outbound.persistence.api.error.UniqueConstraintViolationException;
|
||||
import dev.caskeleton.adapter.outbound.persistence.api.error.VendorFailureTranslator;
|
||||
import dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintCatalog;
|
||||
import dev.caskeleton.adapter.outbound.persistence.postgresql.error.ConstraintCatalog;
|
||||
import dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlExceptionTranslator;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Duration;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
/**
|
||||
* The vendor composition supplies the translator the transaction executor classifies with.
|
||||
*
|
||||
* <p>Nothing registered a {@link VendorFailureTranslator}, so every executor was built with the
|
||||
* catalog-free chain, whose vendor stage returns failures unchanged. A 40001 or 40P01 stayed a raw
|
||||
* {@code DataAccessException}, missed the retry coordinator's {@code JpaPersistenceException}
|
||||
* catch, and was never retried — while {@code PostgreSqlExceptionTranslator}, the class that knows
|
||||
* how to classify both, had no production caller at all.
|
||||
*/
|
||||
class PostgreSqlVendorFailureTranslatorRegistrationTest {
|
||||
|
||||
private static final PersistenceOperationName OPERATION =
|
||||
new PersistenceOperationName("order.write");
|
||||
|
||||
private static final ConstraintCode ACTIVE_EMAIL = new ConstraintCode("user.active-email.unique");
|
||||
|
||||
private final PostgreSqlPersistenceConfig configuration = new PostgreSqlPersistenceConfig();
|
||||
|
||||
@Test
|
||||
@DisplayName("a serialization failure is classified as retryable contention")
|
||||
void aSerializationFailureIsClassifiedAsRetryable() {
|
||||
JpaPersistenceException classified =
|
||||
translate(absent(), new SQLException("could not serialize access", "40001"));
|
||||
|
||||
assertThat(classified.category()).isEqualTo(FailureCategory.SERIALIZATION_FAILURE);
|
||||
assertThat(classified.retryable())
|
||||
.as("the coordinator retries what is marked retryable, and nothing else")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a deadlock is classified as retryable contention")
|
||||
void aDeadlockIsClassifiedAsRetryable() {
|
||||
JpaPersistenceException classified =
|
||||
translate(absent(), new SQLException("deadlock detected", "40P01"));
|
||||
|
||||
assertThat(classified.category()).isEqualTo(FailureCategory.DEADLOCK);
|
||||
assertThat(classified.retryable()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a unique violation is classified and stays terminal")
|
||||
void aUniqueViolationStaysTerminal() {
|
||||
JpaPersistenceException classified =
|
||||
translate(absent(), new SQLException("duplicate key", "23505"));
|
||||
|
||||
assertThat(classified).isInstanceOf(UniqueConstraintViolationException.class);
|
||||
assertThat(classified.retryable())
|
||||
.as("retrying a duplicate key writes the row a second time")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
/**
|
||||
* A registered catalog is taken, and its absence is not an error.
|
||||
*
|
||||
* <p>The catalog is optional on purpose: an empty one still classifies the SQLSTATE, which is
|
||||
* what retry depends on, and resolves every constraint name to the unknown code — the right
|
||||
* answer for a constraint the application never registered. Requiring one would make a relational
|
||||
* deployment fail to start over an error-reporting detail.
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("the application's own constraint catalog is taken when it registered one")
|
||||
void theApplicationsCatalogIsTakenWhenPresent() {
|
||||
ConstraintCatalog registered =
|
||||
new PostgreSqlConstraintCatalog(Map.of("ux_user_active_email", ACTIVE_EMAIL));
|
||||
|
||||
assertThat(configuration.postgreSqlVendorFailureTranslator(available(registered)))
|
||||
.isInstanceOf(PostgreSqlExceptionTranslator.class);
|
||||
assertThat(configuration.postgreSqlVendorFailureTranslator(absent()))
|
||||
.isInstanceOf(PostgreSqlExceptionTranslator.class);
|
||||
}
|
||||
|
||||
private JpaPersistenceException translate(
|
||||
ObjectProvider<ConstraintCatalog> catalogs, Throwable failure) {
|
||||
return configuration
|
||||
.postgreSqlVendorFailureTranslator(catalogs)
|
||||
.translate(failure, OPERATION, 1, Duration.ZERO, null);
|
||||
}
|
||||
|
||||
/** An application that registered no catalog of its own. */
|
||||
private static ObjectProvider<ConstraintCatalog> absent() {
|
||||
return provider(List.of());
|
||||
}
|
||||
|
||||
/** An application that registered exactly one. */
|
||||
private static ObjectProvider<ConstraintCatalog> available(ConstraintCatalog catalog) {
|
||||
return provider(List.of(catalog));
|
||||
}
|
||||
|
||||
private static ObjectProvider<ConstraintCatalog> provider(List<ConstraintCatalog> candidates) {
|
||||
return new ObjectProvider<>() {
|
||||
|
||||
@Override
|
||||
public ConstraintCatalog getObject() {
|
||||
if (candidates.isEmpty()) {
|
||||
throw new NoSuchBeanDefinitionException(ConstraintCatalog.class);
|
||||
}
|
||||
return candidates.get(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<ConstraintCatalog> iterator() {
|
||||
return candidates.iterator();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import dev.caskeleton.application.idempotency.RequestFingerprint;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimRequest;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyScopeDigest;
|
||||
import dev.caskeleton.application.transaction.OperationId;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.springframework.jdbc.core.ConnectionCallback;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/**
|
||||
* The three things that must be true before a claim writes anything.
|
||||
*
|
||||
* <p>Now that a deployment can select this store by property, "which database is behind it" stops
|
||||
* being a question the test harness answers and becomes one an operator can get wrong. The store's
|
||||
* statements are PostgreSQL — {@code on conflict ... do nothing}, {@code clock_timestamp()}, {@code
|
||||
* for update} — so on any other vendor the first claim has to refuse rather than discover the
|
||||
* mismatch halfway through a mutation.
|
||||
*
|
||||
* <p>Each case asserts that no statement ran, not only that an exception was thrown. A guard that
|
||||
* fails after its first write is not a guard.
|
||||
*/
|
||||
class OwnerSafeIdempotencyPreconditionTest {
|
||||
|
||||
private static final IdempotencyScopeDigest SCOPE =
|
||||
new IdempotencyScopeDigest("b".repeat(64), 1, "CREATE_WORK_LOG");
|
||||
|
||||
private final JdbcOperations jdbc = mock(JdbcOperations.class);
|
||||
private final PostgreSqlOwnerSafeIdempotencyStore store =
|
||||
new PostgreSqlOwnerSafeIdempotencyStore(jdbc);
|
||||
|
||||
@AfterEach
|
||||
void clearTransactionState() {
|
||||
TransactionSynchronizationManager.setActualTransactionActive(false);
|
||||
TransactionSynchronizationManager.setCurrentTransactionReadOnly(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a claim with no transaction is refused before any statement runs")
|
||||
void aClaimWithNoTransactionIsRefused() {
|
||||
assertThatThrownBy(() -> store.claim(claimRequest()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("active primary transaction");
|
||||
|
||||
verifyNothingWasWritten();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a claim against another vendor is refused before any statement runs")
|
||||
void aClaimAgainstAnotherVendorIsRefused() throws Exception {
|
||||
TransactionSynchronizationManager.setActualTransactionActive(true);
|
||||
reportDatabaseProduct("H2");
|
||||
|
||||
assertThatThrownBy(() -> store.claim(claimRequest()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("requires PostgreSQL")
|
||||
.hasMessageContaining("H2");
|
||||
|
||||
verifyNothingWasWritten();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a claim against an unsanctioned schema stream is refused before any statement runs")
|
||||
void aClaimAgainstAnUnsanctionedStreamIsRefused() throws Exception {
|
||||
TransactionSynchronizationManager.setActualTransactionActive(true);
|
||||
reportDatabaseProduct("PostgreSQL");
|
||||
when(jdbc.queryForObject(anyString(), eq(Integer.class))).thenReturn(0);
|
||||
|
||||
assertThatThrownBy(() -> store.claim(claimRequest()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("jpa-idempotency-owner-safe-v2 is not active");
|
||||
|
||||
verifyNothingWasWritten();
|
||||
}
|
||||
|
||||
private void reportDatabaseProduct(String product) throws Exception {
|
||||
DatabaseMetaData metaData = mock(DatabaseMetaData.class);
|
||||
when(metaData.getDatabaseProductName()).thenReturn(product);
|
||||
Connection connection = mock(Connection.class);
|
||||
when(connection.getMetaData()).thenReturn(metaData);
|
||||
when(jdbc.execute(ArgumentMatchers.<ConnectionCallback<String>>any()))
|
||||
.thenAnswer(
|
||||
invocation ->
|
||||
invocation.getArgument(0, ConnectionCallback.class).doInConnection(connection));
|
||||
}
|
||||
|
||||
private void verifyNothingWasWritten() {
|
||||
verify(jdbc, never()).update(anyString(), any(Object[].class));
|
||||
}
|
||||
|
||||
private static IdempotencyClaimRequest claimRequest() {
|
||||
return new IdempotencyClaimRequest(
|
||||
SCOPE,
|
||||
RequestFingerprint.ofSha256("request".getBytes(StandardCharsets.UTF_8)),
|
||||
new dev.caskeleton.application.idempotency.v2.IdempotencyClaimAttempt(
|
||||
"c".repeat(64), new OperationId("claim-precondition")),
|
||||
Duration.ofSeconds(30),
|
||||
Duration.ofHours(1),
|
||||
"json.v1",
|
||||
2);
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.testkit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.testkit.arch.JpaAuditMechanismRule;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The bulk-update audit rule, with its failing case executed.
|
||||
*
|
||||
* <p>The production graph contains no bulk update of an audited entity today, so running the rule
|
||||
* over the graph can only ever show it passing — which is the same evidence an empty rule would
|
||||
* produce. The decision the rule encodes is exercised here directly, against statements written for
|
||||
* the purpose, so the rule is known to distinguish the two cases rather than assumed to.
|
||||
*/
|
||||
class JpaAuditMechanismRuleTest {
|
||||
|
||||
private static final Set<String> AUDITED = Set.of("WorkLogEntity", "PosterEntity");
|
||||
|
||||
@Test
|
||||
@DisplayName("a bulk update of an audited entity that stamps nothing is a violation")
|
||||
void aBulkUpdateThatStampsNothingIsAViolation() {
|
||||
assertThat(
|
||||
JpaAuditMechanismRule.bulkUpdateViolation(
|
||||
"update WorkLogEntity w set w.status = :status where w.id = :id", AUDITED))
|
||||
.as("the row would keep the timestamp of whoever last saved it through the adapter")
|
||||
.isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a bulk update that stamps the audit column is accepted")
|
||||
void aBulkUpdateThatStampsTheAuditColumnIsAccepted() {
|
||||
assertThat(
|
||||
JpaAuditMechanismRule.bulkUpdateViolation(
|
||||
"update WorkLogEntity w set w.status = :status, w.updatedAt = :now where w.id = :id",
|
||||
AUDITED))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a bulk update of an entity that uses no audit mechanism is not the rule's business")
|
||||
void aBulkUpdateOfAnUnauditedEntityIsNotTheRulesBusiness() {
|
||||
assertThat(
|
||||
JpaAuditMechanismRule.bulkUpdateViolation(
|
||||
"update UploadSessionEntity u set u.state = :state where u.id = :id", AUDITED))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a select over an audited entity is not a bulk update")
|
||||
void aSelectOverAnAuditedEntityIsNotABulkUpdate() {
|
||||
assertThat(
|
||||
JpaAuditMechanismRule.bulkUpdateViolation(
|
||||
"select w from WorkLogEntity w where w.id = :id", AUDITED))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the Spring Data mechanism's column name counts as stamping too")
|
||||
void theSpringDataColumnNameCountsAsStamping() {
|
||||
assertThat(
|
||||
JpaAuditMechanismRule.bulkUpdateViolation(
|
||||
"update PosterEntity p set p.title = :title, p.modifiedAt = :now", AUDITED))
|
||||
.as("both mechanisms exist, so both column families satisfy the policy")
|
||||
.isEmpty();
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.testkit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.testkit.release.JpaReleaseManifest;
|
||||
import dev.caskeleton.adapter.outbound.persistence.testkit.release.JpaReleaseRendering;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The registry's two renderings, compared to the registry.
|
||||
*
|
||||
* <p>Moving the support claims into a typed registry stopped a version from being supported because
|
||||
* a paragraph mentioned it. It did not stop the opposite: the support document and the release
|
||||
* workflow still carry their own hand-written major lists, support levels and gate table, and
|
||||
* nothing compared them to the registry. Demoting PostgreSQL 17 there would have left the document
|
||||
* calling it Stable and the workflow still running a full job against it — the same class of false
|
||||
* evidence, one file over.
|
||||
*
|
||||
* <p>Each drift case is also executed against a mutated copy, because a comparison nobody has
|
||||
* watched fail is a comparison of unknown shape.
|
||||
*/
|
||||
class JpaReleaseRenderingTest {
|
||||
|
||||
private final JpaReleaseManifest manifest = JpaReleaseManifest.loadFromRepository();
|
||||
private final String supportMatrix = read("docs/jpa/support-matrix.md");
|
||||
private final String releaseWorkflow = read(".github/workflows/jpa-release.yml");
|
||||
private final String nightlyWorkflow = read(".github/workflows/jpa-nightly.yml");
|
||||
|
||||
@Test
|
||||
@DisplayName("the support document states the registry's support level for every major")
|
||||
void theDocumentStatesTheRegistrysSupportLevels() {
|
||||
Map<Integer, String> documented = JpaReleaseRendering.documentedSupportLevels(supportMatrix);
|
||||
|
||||
assertThat(documented.entrySet())
|
||||
.as("a major in the document and not in the registry is a support claim nothing backs")
|
||||
.allSatisfy(
|
||||
row ->
|
||||
assertThat(expectedLevel(row.getKey()))
|
||||
.as("PostgreSQL %d", row.getKey())
|
||||
.isEqualTo(row.getValue()));
|
||||
assertThat(documented.keySet())
|
||||
.containsExactlyInAnyOrderElementsOf(
|
||||
java.util.stream.Stream.concat(
|
||||
manifest.stableVersions().stream(), manifest.experimentalVersions().stream())
|
||||
.toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a demotion in the registry that the document does not follow is caught")
|
||||
void aDemotionTheDocumentDoesNotFollowIsCaught() {
|
||||
String demoted = supportMatrix.replace("| PostgreSQL 17 | Stable |", "| PostgreSQL 17 | Bad |");
|
||||
|
||||
assertThat(JpaReleaseRendering.documentedSupportLevels(demoted).get(17))
|
||||
.as(
|
||||
"the check reads the document's own words, so a level the registry does not state fails")
|
||||
.isNotEqualTo(expectedLevel(17));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the document's gate table is exactly the registry's gates")
|
||||
void theDocumentsGateTableIsExactlyTheRegistrysGates() {
|
||||
assertThat(JpaReleaseRendering.documentedGates(supportMatrix))
|
||||
.as("a gate deleted from the registry must not keep a row that says it is checked")
|
||||
.containsExactlyInAnyOrderElementsOf(manifest.gates());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a gate row the registry does not declare is caught")
|
||||
void aGateRowTheRegistryDoesNotDeclareIsCaught() {
|
||||
String invented =
|
||||
supportMatrix.replace(
|
||||
"| `osiv-disabled` | gate |", "| `osiv-disabled` | gate |\n| `invented-gate` | gate |");
|
||||
|
||||
assertThat(JpaReleaseRendering.documentedGates(invented))
|
||||
.as("a gate table row with no registry entry is drift the positive case forbids")
|
||||
.isNotEqualTo(manifest.gates());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the release workflow fans out to exactly the Stable majors")
|
||||
void theReleaseWorkflowFansOutToExactlyTheStableMajors() {
|
||||
assertThat(JpaReleaseRendering.workflowMatrixMajors(releaseWorkflow))
|
||||
.as("a major the registry does not call Stable must not get a full release job")
|
||||
.containsExactlyElementsOf(manifest.stableVersions());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the promotion job requires evidence for exactly the Stable majors")
|
||||
void thePromotionJobRequiresEvidenceForExactlyTheStableMajors() {
|
||||
assertThat(JpaReleaseRendering.workflowPromotionMajors(releaseWorkflow))
|
||||
.as("promotion that checks fewer majors than the matrix runs ignores a failed one")
|
||||
.containsExactlyElementsOf(manifest.stableVersions());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a workflow matrix that drops a Stable major is caught")
|
||||
void aWorkflowMatrixThatDropsAStableMajorIsCaught() {
|
||||
String dropped = releaseWorkflow.replace("[\"16\", \"17\", \"18\"]", "[\"16\", \"17\"]");
|
||||
|
||||
assertThat(JpaReleaseRendering.workflowMatrixMajors(dropped))
|
||||
.isNotEqualTo(manifest.stableVersions());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the nightly matrix fans out to exactly the Stable majors")
|
||||
void theNightlyMatrixFansOutToExactlyTheStableMajors() {
|
||||
assertThat(JpaReleaseRendering.workflowMatrixMajors(nightlyWorkflow))
|
||||
.as(
|
||||
"the release lane was compared to the registry and the nightly lane was not, so a "
|
||||
+ "demotion corrected one workflow and left the other certifying the major")
|
||||
.containsExactlyElementsOf(manifest.stableVersions());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a nightly matrix that certifies an unregistered major is caught")
|
||||
void aNightlyMatrixThatCertifiesAnUnregisteredMajorIsCaught() {
|
||||
String widened =
|
||||
nightlyWorkflow.replace("[\"16\", \"17\", \"18\"]", "[\"16\", \"17\", \"18\", \"19\"]");
|
||||
|
||||
assertThat(JpaReleaseRendering.workflowMatrixMajors(widened))
|
||||
.as("collecting full-contract evidence for a major is the promotion the gate owns")
|
||||
.isNotEqualTo(manifest.stableVersions());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("every Experimental major is named as the target of a compatibility lane")
|
||||
void everyExperimentalMajorIsNamedByACompatibilityLane() {
|
||||
assertThat(compatibilityTargetsAcrossWorkflows())
|
||||
.as(
|
||||
"'compatibility lane only' has to resolve to a file that records that major as its "
|
||||
+ "target, or the support level is a claim the reader cannot check")
|
||||
.containsAll(manifest.experimentalVersions());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("declaring a major Experimental is not enough on its own")
|
||||
void declaringAMajorExperimentalIsNotEnoughOnItsOwn() {
|
||||
JpaReleaseManifest invented =
|
||||
JpaReleaseManifest.parse(
|
||||
"""
|
||||
{"databases":[{"major":16,"support-level":"stable"},
|
||||
{"major":99,"support-level":"experimental"}],
|
||||
"provider":{"stable-tested-baseline":"7.1.8.Final"},
|
||||
"gates":[{"name":"osiv-disabled","task":":a:test"}]}
|
||||
""");
|
||||
|
||||
assertThat(compatibilityTargetsAcrossWorkflows())
|
||||
.as("the positive case must fail for a major nobody wrote a lane for")
|
||||
.doesNotContainAnyElementsOf(invented.experimentalVersions());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no Experimental major collects evidence from a Stable lane")
|
||||
void noExperimentalMajorCollectsEvidenceFromAStableLane() {
|
||||
List<Integer> stableLaneMajors = new ArrayList<>();
|
||||
stableLaneMajors.addAll(JpaReleaseRendering.workflowExecutedMajors(releaseWorkflow));
|
||||
stableLaneMajors.addAll(JpaReleaseRendering.workflowExecutedMajors(nightlyWorkflow));
|
||||
|
||||
assertThat(stableLaneMajors)
|
||||
.as("a compatibility target that also runs the full suite is Stable in everything but name")
|
||||
.doesNotContainAnyElementsOf(manifest.experimentalVersions());
|
||||
}
|
||||
|
||||
private String expectedLevel(int major) {
|
||||
if (manifest.stableVersions().contains(major)) {
|
||||
return "stable";
|
||||
}
|
||||
if (manifest.experimentalVersions().contains(major)) {
|
||||
return "experimental";
|
||||
}
|
||||
return "unregistered";
|
||||
}
|
||||
|
||||
/**
|
||||
* Every major any compatibility lane in the workflow directory records as its target.
|
||||
*
|
||||
* <p>The whole directory rather than a named file: the check is "some lane targets this major",
|
||||
* and naming the file here would make the test pass by pointing at the one lane that exists.
|
||||
*/
|
||||
private static List<Integer> compatibilityTargetsAcrossWorkflows() {
|
||||
List<Integer> targets = new ArrayList<>();
|
||||
Path directory = locate(".github/workflows");
|
||||
try (var entries = Files.list(directory)) {
|
||||
for (Path workflow : entries.sorted().toList()) {
|
||||
if (workflow.getFileName().toString().endsWith(".yml")) {
|
||||
targets.addAll(
|
||||
JpaReleaseRendering.compatibilityLaneTargets(
|
||||
Files.readString(workflow, StandardCharsets.UTF_8)));
|
||||
}
|
||||
}
|
||||
} catch (IOException unreadable) {
|
||||
throw new IllegalStateException(
|
||||
"cannot read the workflow directory " + directory, unreadable);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
/** Reads a repository-relative file, locating the root by walking up. */
|
||||
private static String read(String repositoryRelativePath) {
|
||||
Path path = locate(repositoryRelativePath);
|
||||
try {
|
||||
return Files.readString(path, StandardCharsets.UTF_8);
|
||||
} catch (IOException unreadable) {
|
||||
throw new IllegalStateException("cannot read " + path, unreadable);
|
||||
}
|
||||
}
|
||||
|
||||
/** Locates a repository-relative file or directory by walking up from the working directory. */
|
||||
private static Path locate(String repositoryRelativePath) {
|
||||
for (Path directory = Path.of("").toAbsolutePath();
|
||||
directory != null;
|
||||
directory = directory.getParent()) {
|
||||
Path candidate = directory.resolve(repositoryRelativePath);
|
||||
if (Files.exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"cannot locate " + repositoryRelativePath + " from " + Path.of("").toAbsolutePath());
|
||||
}
|
||||
}
|
||||
+145
-11
@@ -1,13 +1,22 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.testkit.arch;
|
||||
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods;
|
||||
|
||||
import com.tngtech.archunit.base.DescribedPredicate;
|
||||
import com.tngtech.archunit.core.domain.JavaClass;
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.domain.JavaMethod;
|
||||
import com.tngtech.archunit.lang.ArchCondition;
|
||||
import com.tngtech.archunit.lang.ArchRule;
|
||||
import com.tngtech.archunit.lang.ConditionEvents;
|
||||
import com.tngtech.archunit.lang.SimpleConditionEvent;
|
||||
import jakarta.persistence.Entity;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* An entity uses one audit mechanism, or none — never both.
|
||||
@@ -43,6 +52,142 @@ public final class JpaAuditMechanismRule {
|
||||
.allowEmptyShould(true);
|
||||
}
|
||||
|
||||
/** The simple names of the entities in {@code classes} that use either audit mechanism. */
|
||||
public static Set<String> auditedEntityNames(JavaClasses classes) {
|
||||
return classes.stream()
|
||||
.filter(type -> type.isAnnotatedWith(Entity.class))
|
||||
.filter(JpaAuditMechanismRule::usesAnAuditMechanism)
|
||||
.map(JavaClass::getSimpleName)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* A bulk update of an audited entity must stamp the audit columns itself.
|
||||
*
|
||||
* <p>Neither mechanism reaches a bulk statement. The explicit base stamps in the repository
|
||||
* adapter's {@code save}; the Spring Data listener fires on a managed entity's lifecycle. A JPQL
|
||||
* or native {@code update} goes straight to the database, so an audited row updated in bulk keeps
|
||||
* whatever {@code updated_at} and {@code updated_by} it had — and the audit trail then says the
|
||||
* row was last touched by whoever last saved it through the adapter, which is a wrong answer
|
||||
* rather than a missing one.
|
||||
*
|
||||
* <p>The rule takes the audited names rather than deriving them, because the check runs over the
|
||||
* composition root's view of every leaf while the entities live in another one. Passing the set
|
||||
* in keeps the rule honest about what it is comparing against.
|
||||
*
|
||||
* @param auditedEntities simple names of entities that use an audit mechanism
|
||||
*/
|
||||
public static ArchRule bulkUpdatesOfAuditedEntitiesStampAudit(Set<String> auditedEntities) {
|
||||
Set<String> audited = Set.copyOf(auditedEntities);
|
||||
return methods()
|
||||
.that(
|
||||
new DescribedPredicate<JavaMethod>("are annotated with Spring Data @Modifying") {
|
||||
@Override
|
||||
public boolean test(JavaMethod method) {
|
||||
return method.getAnnotations().stream()
|
||||
.anyMatch(annotation -> MODIFYING.equals(annotation.getRawType().getName()));
|
||||
}
|
||||
})
|
||||
.should(new StampsAuditOnBulkUpdate(audited))
|
||||
.as("a bulk update of an audited entity stamps the audit columns in the statement")
|
||||
.because(
|
||||
"no audit mechanism reaches a bulk statement, so an unstamped one leaves the row"
|
||||
+ " claiming it was last modified by the previous ordinary save")
|
||||
.allowEmptyShould(true);
|
||||
}
|
||||
|
||||
/** Whether {@code entity} inherits the explicitly stamped audit base. */
|
||||
private static boolean extendsAuditableEntity(JavaClass entity) {
|
||||
for (JavaClass current = entity;
|
||||
current != null;
|
||||
current = current.getRawSuperclass().orElse(null)) {
|
||||
if (current.getName().equals(EXPLICIT_BASE)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether {@code entity} uses either audit mechanism. */
|
||||
private static boolean usesAnAuditMechanism(JavaClass entity) {
|
||||
return extendsAuditableEntity(entity)
|
||||
|| entity.getAllFields().stream()
|
||||
.anyMatch(field -> field.getRawType().getName().equals(SPRING_DATA_EMBEDDABLE));
|
||||
}
|
||||
|
||||
/**
|
||||
* The violation in one query, when there is one.
|
||||
*
|
||||
* <p>Exposed so both branches can be executed directly. An architecture rule whose passing and
|
||||
* failing cases have never been run is a rule of unknown shape, and the production graph happens
|
||||
* to contain no audited bulk update today — so the graph alone can only ever show it passing.
|
||||
*
|
||||
* @param query the JPQL or native statement
|
||||
* @param auditedEntities simple names of entities that use an audit mechanism
|
||||
* @return the reason this query violates the rule, or empty
|
||||
*/
|
||||
public static Optional<String> bulkUpdateViolation(String query, Set<String> auditedEntities) {
|
||||
String normalized = query.trim().toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
|
||||
if (!normalized.startsWith("update ")) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String target = normalized.substring("update ".length()).split(" ", -1)[0];
|
||||
boolean audited =
|
||||
auditedEntities.stream().anyMatch(name -> name.toLowerCase(Locale.ROOT).equals(target));
|
||||
if (!audited) {
|
||||
return Optional.empty();
|
||||
}
|
||||
int setClause = normalized.indexOf(" set ");
|
||||
if (setClause < 0) {
|
||||
return Optional.of("updates audited '" + target + "' with no set clause at all");
|
||||
}
|
||||
int whereClause = normalized.indexOf(" where ", setClause);
|
||||
String assignments =
|
||||
whereClause < 0
|
||||
? normalized.substring(setClause)
|
||||
: normalized.substring(setClause, whereClause);
|
||||
boolean stamps = AUDIT_COLUMNS.stream().anyMatch(assignments::contains);
|
||||
return stamps
|
||||
? Optional.empty()
|
||||
: Optional.of("updates audited '" + target + "' without stamping an audit column");
|
||||
}
|
||||
|
||||
/** The Spring Data annotation that marks a repository method as a bulk statement. */
|
||||
private static final String MODIFYING = "org.springframework.data.jpa.repository.Modifying";
|
||||
|
||||
/** The Spring Data annotation carrying the statement text. */
|
||||
private static final String QUERY = "org.springframework.data.jpa.repository.Query";
|
||||
|
||||
/** The assignments that count as stamping, across both mechanisms' column names. */
|
||||
private static final Set<String> AUDIT_COLUMNS =
|
||||
Set.of("updatedat", "updated_at", "modifiedat", "modified_at");
|
||||
|
||||
private static final class StampsAuditOnBulkUpdate extends ArchCondition<JavaMethod> {
|
||||
|
||||
private final Set<String> auditedEntities;
|
||||
|
||||
StampsAuditOnBulkUpdate(Set<String> auditedEntities) {
|
||||
super("stamp the audit columns when the target entity is audited");
|
||||
this.auditedEntities = auditedEntities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void check(JavaMethod method, ConditionEvents events) {
|
||||
method.getAnnotations().stream()
|
||||
.filter(annotation -> QUERY.equals(annotation.getRawType().getName()))
|
||||
.map(annotation -> annotation.get("value").orElse(""))
|
||||
.map(Object::toString)
|
||||
.forEach(
|
||||
statement ->
|
||||
bulkUpdateViolation(statement, auditedEntities)
|
||||
.ifPresent(
|
||||
reason ->
|
||||
events.add(
|
||||
SimpleConditionEvent.violated(
|
||||
method, method.getFullName() + " " + reason))));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SingleAuditMechanism extends ArchCondition<JavaClass> {
|
||||
|
||||
SingleAuditMechanism() {
|
||||
@@ -65,16 +210,5 @@ public final class JpaAuditMechanismRule {
|
||||
+ " and two column families"));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean extendsAuditableEntity(JavaClass entity) {
|
||||
for (JavaClass current = entity;
|
||||
current != null;
|
||||
current = current.getRawSuperclass().orElse(null)) {
|
||||
if (current.getName().equals(EXPLICIT_BASE)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.testkit.release;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* What the support document and the release workflow say, extracted so it can be compared.
|
||||
*
|
||||
* <p>The registry became the source of truth and nothing checked that its two renderings agreed
|
||||
* with it. A document that opens by calling itself a rendering is a claim, not a mechanism: the
|
||||
* major list, the support levels, the gate table and the workflow's matrix were all still typed by
|
||||
* hand, so demoting a major in the registry left three places saying it was Stable and one job per
|
||||
* major still running against it.
|
||||
*
|
||||
* <p>These readers take text rather than paths so a drift check can be shown to fail on a mutated
|
||||
* copy. A comparison whose negative case has never been executed is a comparison of unknown shape.
|
||||
*/
|
||||
public final class JpaReleaseRendering {
|
||||
|
||||
private static final Pattern DOCUMENT_DATABASE_ROW =
|
||||
Pattern.compile("^\\|\\s*PostgreSQL (\\d+)\\s*\\|\\s*([A-Za-z]+)\\s*\\|", Pattern.MULTILINE);
|
||||
private static final Pattern DOCUMENT_GATE_ROW =
|
||||
Pattern.compile("^\\|\\s*`([a-z0-9-]+)`\\s*\\|\\s*gate\\s*\\|", Pattern.MULTILINE);
|
||||
private static final Pattern WORKFLOW_MATRIX = Pattern.compile("postgresql:\\s*\\[([^\\]]*)\\]");
|
||||
private static final Pattern WORKFLOW_PROMOTION_LOOP = Pattern.compile("for major in ([0-9 ]+);");
|
||||
private static final Pattern QUOTED_MAJOR = Pattern.compile("\"(\\d+)\"");
|
||||
private static final Pattern COMPATIBILITY_TARGET =
|
||||
Pattern.compile("target=PostgreSQL\\s+(\\d+)");
|
||||
|
||||
private JpaReleaseRendering() {}
|
||||
|
||||
/**
|
||||
* The support level the document states for each major, keyed by major in document order.
|
||||
*
|
||||
* <p>Only the database table's rows match: the pattern anchors on a table cell, so a major named
|
||||
* in a paragraph is not a claim of support. That was the original defect in the other direction —
|
||||
* prose counted as a declaration.
|
||||
*/
|
||||
public static Map<Integer, String> documentedSupportLevels(String document) {
|
||||
Objects.requireNonNull(document, "document");
|
||||
Map<Integer, String> levels = new LinkedHashMap<>();
|
||||
Matcher rows = DOCUMENT_DATABASE_ROW.matcher(document);
|
||||
while (rows.find()) {
|
||||
levels.put(Integer.parseInt(rows.group(1)), rows.group(2).toLowerCase(java.util.Locale.ROOT));
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
/** The gate names the document's gate table lists, in document order. */
|
||||
public static List<String> documentedGates(String document) {
|
||||
Objects.requireNonNull(document, "document");
|
||||
List<String> gates = new ArrayList<>();
|
||||
Matcher rows = DOCUMENT_GATE_ROW.matcher(document);
|
||||
while (rows.find()) {
|
||||
gates.add(rows.group(1));
|
||||
}
|
||||
return gates;
|
||||
}
|
||||
|
||||
/** The majors the release workflow fans out to, one job each. */
|
||||
public static List<Integer> workflowMatrixMajors(String workflow) {
|
||||
Objects.requireNonNull(workflow, "workflow");
|
||||
Matcher matrix = WORKFLOW_MATRIX.matcher(workflow);
|
||||
if (!matrix.find()) {
|
||||
throw new IllegalArgumentException("the release workflow declares no PostgreSQL matrix");
|
||||
}
|
||||
List<Integer> majors = new ArrayList<>();
|
||||
Matcher quoted = QUOTED_MAJOR.matcher(matrix.group(1));
|
||||
while (quoted.find()) {
|
||||
majors.add(Integer.parseInt(quoted.group(1)));
|
||||
}
|
||||
return majors;
|
||||
}
|
||||
|
||||
/**
|
||||
* The majors the promotion job requires evidence for.
|
||||
*
|
||||
* <p>Read separately from the matrix on purpose. They are two hand-written lists in one file, and
|
||||
* a promotion that requires fewer majors than the matrix produces is a promotion that ignores a
|
||||
* failed one.
|
||||
*/
|
||||
public static List<Integer> workflowPromotionMajors(String workflow) {
|
||||
Objects.requireNonNull(workflow, "workflow");
|
||||
Matcher loop = WORKFLOW_PROMOTION_LOOP.matcher(workflow);
|
||||
if (!loop.find()) {
|
||||
throw new IllegalArgumentException("the release workflow declares no promotion major list");
|
||||
}
|
||||
List<Integer> majors = new ArrayList<>();
|
||||
for (String major : loop.group(1).trim().split("\\s+", -1)) {
|
||||
majors.add(Integer.parseInt(major));
|
||||
}
|
||||
return majors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every major some job in a workflow fans out to.
|
||||
*
|
||||
* <p>{@link #workflowMatrixMajors} reads the first matrix it finds and is the exactness check for
|
||||
* the release lane. This answers a weaker but differently useful question — does any job in this
|
||||
* file start a container of this major — so a second workflow can be compared to the registry
|
||||
* without assuming it declares exactly one matrix.
|
||||
*/
|
||||
public static List<Integer> workflowExecutedMajors(String workflow) {
|
||||
Objects.requireNonNull(workflow, "workflow");
|
||||
List<Integer> majors = new ArrayList<>();
|
||||
Matcher matrices = WORKFLOW_MATRIX.matcher(workflow);
|
||||
while (matrices.find()) {
|
||||
Matcher quoted = QUOTED_MAJOR.matcher(matrices.group(1));
|
||||
while (quoted.find()) {
|
||||
majors.add(Integer.parseInt(quoted.group(1)));
|
||||
}
|
||||
}
|
||||
return majors;
|
||||
}
|
||||
|
||||
/**
|
||||
* The majors a compatibility lane records itself as targeting.
|
||||
*
|
||||
* <p>An Experimental major is declared "compatibility lane only" and nothing tied that phrase to
|
||||
* a file. The lane does exist — it runs weekly and records {@code NOT_EXECUTABLE} with its reason
|
||||
* — but the registry, the support document and the release workflow could each be read end to end
|
||||
* without establishing that. A reader who cannot find the lane concludes there is none, which is
|
||||
* the conclusion this repository's own review pass reached before checking the workflow
|
||||
* directory.
|
||||
*
|
||||
* <p>The target is read from the status artifact the lane writes rather than from its filename. A
|
||||
* filename is a label; the artifact is what a promotion decision would actually be read from, so
|
||||
* a lane that stops recording its target stops counting as a lane.
|
||||
*/
|
||||
public static List<Integer> compatibilityLaneTargets(String workflow) {
|
||||
Objects.requireNonNull(workflow, "workflow");
|
||||
List<Integer> targets = new ArrayList<>();
|
||||
Matcher declared = COMPATIBILITY_TARGET.matcher(workflow);
|
||||
while (declared.find()) {
|
||||
targets.add(Integer.parseInt(declared.group(1)));
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,19 @@ design package's assumed module layout onto this leaf lives in
|
||||
## Allowed
|
||||
|
||||
- No project dependency at all. The registry entry's `allowed_dependencies` is `[]`, matching what
|
||||
the build actually uses; `application-core` and `shared-contract` were listed and unused, which is
|
||||
a permission granted in advance for an adapter nobody has approved yet.
|
||||
- `runtime_memberships` is `[]` and no composition root depends on this leaf. Property-only
|
||||
activation switches on a module that is already on the classpath; it does not put one there. A
|
||||
fork that wants it in a runtime adds the membership and the dependency in the same approved
|
||||
change.
|
||||
the build actually uses; `domain-core`, `application-core` and `shared-contract` were listed and
|
||||
unused, which is a permission granted in advance for an adapter nobody has approved yet.
|
||||
`verifyCleanArchitectureDependencies` only checks that resolved edges are a subset of the declared
|
||||
ones, so an unused permission passes every run; `MongoRegistryPermissionParityTest` checks the
|
||||
other direction and fails when the two sets differ.
|
||||
- `runtime_memberships` is `["app-bootstrap"]`, and the composition root really does declare
|
||||
`implementation(project(':adapter:outbound:persistence-mongo'))` — with the reactive starter and
|
||||
the reactivestreams driver excluded, because there is no reactive port in the shipped Stable
|
||||
scope. `RuntimeMembershipClasspathAgreementTest` compares the registry against the resolved
|
||||
runtime classpath, so the membership cannot drift from what the jar carries. Property-only
|
||||
activation therefore works here: the switch turns on a module that already ships, and shipping it
|
||||
off is not the same contract as leaving it out, because absence cannot be reversed at deploy time
|
||||
and hides every gating defect. `sample-portfolio` does not carry it.
|
||||
- External: `spring-boot-starter-data-mongodb` and `-reactive`, `spring-boot-autoconfigure`,
|
||||
`micrometer-core`, `slf4j-api`, `spring-boot-configuration-processor` (annotation processor).
|
||||
Versions come from the shared Spring Boot BOM; never pin the driver directly.
|
||||
@@ -73,14 +80,19 @@ unsupported rather than silently dropped.
|
||||
|
||||
### Public surface
|
||||
|
||||
341 public top-level types live in one jar, so `public` means public to every adopter regardless of
|
||||
which package it sits in. `verifyMongoApiSurface` (in `check`) compares the surface against
|
||||
`docs/architecture/mongo-api-surface.txt`; growing it takes
|
||||
Every public top-level type in one jar means `public` is public to every adopter regardless of which
|
||||
package it sits in. `verifyMongoApiSurface` (in `check`) compares the surface against
|
||||
`docs/architecture/mongo-api-surface.txt`, which carries the count; growing it takes
|
||||
`updateMongoApiSurface -PapproveMongoApiSurfaceChange`, which is a review decision.
|
||||
|
||||
The architecture rule catalogue (`…mongo.architecture`) is in the **testkit** source set, not
|
||||
production: it is ArchUnit input, and shipping it put rule text on every consumer's runtime
|
||||
classpath.
|
||||
classpath. Release gating is testkit-only for the same reason and is one implementation, not two:
|
||||
`…mongo.testkit.release` reads the JUnit XML a lane wrote and is what
|
||||
`scripts/verify-mongodb-platform.sh` and `src/config/mongodb/release-contracts.json` drive. A second
|
||||
pair on the production classpath — a hand-built set of category names and a gate that checked it —
|
||||
had no caller outside its own test and no source of truth behind the categories; it is gone rather
|
||||
than moved.
|
||||
|
||||
Still pending, and deliberately not done as part of a review sweep: moving implementation packages
|
||||
under an `internal` root and lowering visibility inside them. That is a mechanical change over ~200
|
||||
|
||||
@@ -11,20 +11,24 @@ document/repository/mapper와 application 또는 domain port 구현을 추가할
|
||||
|
||||
## 활성화
|
||||
|
||||
### shipped runtime에는 들어 있지 않다 (library-only opt-in)
|
||||
### shipped runtime에 들어 있고, property가 그 스위치다
|
||||
|
||||
property를 켜는 것만으로는 이 leaf가 애플리케이션에 들어오지 않는다. registry의
|
||||
`runtime_memberships`는 빈 배열이고, shipped `app-bootstrap`과 `sample-portfolio`는 이 leaf에
|
||||
project dependency를 두지 않는다. 즉 `ca-skeleton.persistence-mongo.enabled=true`는 **이미 classpath에
|
||||
올라온 모듈**을 켜는 스위치이지, 모듈을 추가하는 스위치가 아니다.
|
||||
`app-bootstrap`은 이 leaf에 project dependency를 두고(`src/app-bootstrap/build.gradle`,
|
||||
reactive starter와 reactivestreams driver는 exclude), registry의 `runtime_memberships`도
|
||||
`["app-bootstrap"]`이다. 두 사실은 `RuntimeMembershipClasspathAgreementTest`가 runtime classpath와
|
||||
비교해 붙잡는다. 그래서 `ca-skeleton.persistence-mongo.enabled=true`는 실제로 동작하는 master
|
||||
switch다 — 없는 모듈을 부르는 property가 아니라, 이미 jar에 들어 있는 모듈을 켜는 스위치다.
|
||||
|
||||
consumer가 실제로 쓰려면 registry(`src/config/architecture/modules.json`)의 runtime membership과
|
||||
composition root의 dependency를 함께 승인해서 추가해야 한다. 그 승인 없이 property만 켜면 아무 일도
|
||||
일어나지 않는다 — 아래 설명은 모두 그 전제 위에 있다.
|
||||
빠져 있는 모듈은 꺼진 모듈과 같은 계약이 아니다. 부재는 배포 시점에 되돌릴 수 없고, gating 결함을
|
||||
전부 가린다. 존재하지 않는 코드는 자기 condition이 무엇이라고 말하든 bean을 하나도 들고 있지 않기
|
||||
때문이다. `sample-portfolio`는 이 leaf를 싣지 않는다.
|
||||
|
||||
`allowed_dependencies`도 같은 이유로 `[]`이다. 지금 이 leaf는 어떤 project dependency도 쓰지 않으므로,
|
||||
`application-core`/`shared-contract`를 열어 두는 것은 "언젠가 쓸지도 모른다"를 registry가 미리 허용해
|
||||
주는 것이고, 그 허용은 실제 도메인 adapter가 승인될 때 함께 추가되어야 한다.
|
||||
`allowed_dependencies`는 `[]`다. 이 leaf는 어떤 project dependency도 쓰지 않으므로 —
|
||||
`domain-core`/`application-core`/`shared-contract`를 열어 두는 것은 "언젠가 쓸지도 모른다"를 registry가
|
||||
미리 허용해 주는 것이었다. `verifyCleanArchitectureDependencies`는 실제 edge가 허용 집합의 부분집합인지만
|
||||
보므로 쓰이지 않는 허용은 영원히 통과한다. `MongoRegistryPermissionParityTest`가 반대 방향 —
|
||||
허용 집합과 build가 선언한 project dependency가 정확히 같은지 — 를 확인한다. 실제 도메인 Mongo adapter가
|
||||
application port를 구현하게 되면 build와 registry를 같은 변경에서 함께 넓힌다.
|
||||
|
||||
기본값은 비활성이다.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// modules map here).
|
||||
//
|
||||
// The design models the platform as 19 Stable and 12 Advanced Gradle modules under
|
||||
// `modules/mongodb` and `modules/mongodb-advanced`. This repository's fail-closed 19-leaf registry
|
||||
// `modules/mongodb` and `modules/mongodb-advanced`. This repository's fail-closed module registry
|
||||
// (src/config/architecture/modules.json) outranks that layout, so the module boundaries are
|
||||
// packages under dev.caskeleton.adapter.outbound.mongo. MongoModuleBoundaryTest holds a closed
|
||||
// edge matrix — every package and what it may import — compares it against the tree for exact
|
||||
@@ -43,33 +43,20 @@ dependencies {
|
||||
// The testkit is its own source set rather than part of `test` because several lanes consume it and
|
||||
// because the design forbids a production module from depending on the testkit. Declaring its
|
||||
// dependencies only on the test configurations gives that guarantee without a new Gradle project.
|
||||
sourceSets {
|
||||
testkit {
|
||||
java.srcDir 'src/testkit/java'
|
||||
resources.srcDir 'src/testkit/resources'
|
||||
compileClasspath += sourceSets.main.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
mongoPerformanceTest {
|
||||
java.srcDir 'src/mongoPerformanceTest/java'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.testkit.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
// The testkit compiles against exactly what a test does: testImplementation already extends
|
||||
// implementation, so this is the module's own dependencies plus the test libraries.
|
||||
testkitImplementation.extendsFrom testImplementation
|
||||
testkitRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
mongoPerformanceTestImplementation.extendsFrom testImplementation
|
||||
mongoPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
strictTestLanes {
|
||||
// The testkit compiles against exactly what a test does: `implementation` inheritance runs
|
||||
// through testImplementation, so this is the module's own dependencies plus the test libraries.
|
||||
sourceSet('testkit') { compilesAgainst 'main' }
|
||||
sourceSet('mongoPerformanceTest') { compilesAgainst 'main', 'testkit' }
|
||||
}
|
||||
|
||||
// Every test lane compiles and runs against the testkit.
|
||||
sourceSets.test {
|
||||
compileClasspath += sourceSets.testkit.output
|
||||
runtimeClasspath += sourceSets.testkit.output
|
||||
//
|
||||
// No publishAs: this leaf's testkit is consumed inside the leaf and is not offered to the
|
||||
// composition root, unlike the JPA one whose ArchUnit rule pack the root applies to the production
|
||||
// graph. Publishing is opt-in in the convention precisely so that difference stays a decision.
|
||||
testkitPublisher {
|
||||
consumedBy 'test'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -113,85 +100,62 @@ tasks.named('test', Test) {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('mongoReplicaSetTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Single-node replica set contract lane: mapping, atomic write, transaction, ' +
|
||||
'change stream (design §29).'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform { includeTags 'mongodb-replicaset' }
|
||||
applyMongoImageSelection(it)
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
// Six lanes, declared rather than assembled. `ca.strict-test-lane` owns testClassesDirs, classpath,
|
||||
// tag selection, failOnNoDiscoveredTests and the up-to-date refusal — the five lines that used to be
|
||||
// copied once per lane here and again in four other leaves.
|
||||
strictTestLanes {
|
||||
lane('mongoReplicaSetTest') {
|
||||
tag = 'mongodb-replicaset'
|
||||
description = 'Single-node replica set contract lane: mapping, atomic write, transaction, ' +
|
||||
'change stream (design §29).'
|
||||
customize = { test -> applyMongoImageSelection(test) }
|
||||
}
|
||||
lane('mongoFailoverTest') {
|
||||
tag = 'mongodb-failover'
|
||||
description = 'Three-node replica set failover lane: primary kill, partition, unknown ' +
|
||||
'commit, resume (design §29).'
|
||||
customize = { test -> applyMongoImageSelection(test) }
|
||||
}
|
||||
lane('mongoMigrationTest') {
|
||||
tag = 'mongodb-migration'
|
||||
description = 'Migration lane: empty / N-1 / oldest-supported snapshots, lock, checkpoint ' +
|
||||
'restart (design §12).'
|
||||
customize = { test -> applyMongoImageSelection(test) }
|
||||
}
|
||||
lane('mongoCompatibilityTest') {
|
||||
tag = 'mongodb-compatibility'
|
||||
description = 'MongoDB 7.0 compatibility and 8.0 primary certification matrix (design §30).'
|
||||
customize = { test -> applyMongoImageSelection(test) }
|
||||
}
|
||||
lane('mongoSecurityIntegrationTest') {
|
||||
tag = 'mongodb-security-integration'
|
||||
description = 'RBAC, TLS, injection and redaction release gate against a real server ' +
|
||||
'(design §26).'
|
||||
customize = { test -> applyMongoImageSelection(test) }
|
||||
}
|
||||
|
||||
// Driven by its own source set rather than a tag: for this shape the source set is the
|
||||
// selection, so the convention asks for no tag.
|
||||
lane('mongoPerformanceTest') {
|
||||
sourceSet = 'mongoPerformanceTest'
|
||||
description = 'Certifies contention, aggregation spill, pagination and pool resource ' +
|
||||
'bounds (design §29).'
|
||||
customize = { test ->
|
||||
applyMongoImageSelection(test)
|
||||
// Assertions on by default. They defaulted to false, so the lane measured numbers and
|
||||
// compared them to nothing — a performance gate whose bounds are never evaluated is a
|
||||
// report, and the release evidence called it a certification.
|
||||
test.systemProperty 'performance.assertions.enabled',
|
||||
(project.findProperty('performance.assertions.enabled') ?: 'true').toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('mongoFailoverTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Three-node replica set failover lane: primary kill, partition, unknown commit, ' +
|
||||
'resume (design §29).'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform { includeTags 'mongodb-failover' }
|
||||
applyMongoImageSelection(it)
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
tasks.register('mongoMigrationTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Migration lane: empty / N-1 / oldest-supported snapshots, lock, checkpoint ' +
|
||||
'restart (design §12).'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform { includeTags 'mongodb-migration' }
|
||||
applyMongoImageSelection(it)
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
tasks.register('mongoCompatibilityTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'MongoDB 7.0 compatibility and 8.0 primary certification matrix (design §30).'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform { includeTags 'mongodb-compatibility' }
|
||||
applyMongoImageSelection(it)
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
tasks.register('mongoSecurityIntegrationTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'RBAC, TLS, injection and redaction release gate against a real server ' +
|
||||
'(design §26).'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform { includeTags 'mongodb-security-integration' }
|
||||
applyMongoImageSelection(it)
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
tasks.register('mongoPerformanceTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Certifies contention, aggregation spill, pagination and pool resource bounds ' +
|
||||
'(design §29).'
|
||||
testClassesDirs = sourceSets.mongoPerformanceTest.output.classesDirs
|
||||
classpath = sourceSets.mongoPerformanceTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
applyMongoImageSelection(it)
|
||||
// Assertions on by default. They defaulted to false, so the lane measured numbers and compared
|
||||
// them to nothing — a performance gate whose bounds are never evaluated is a report, and the
|
||||
// release evidence called it a certification.
|
||||
systemProperty 'performance.assertions.enabled',
|
||||
(project.findProperty('performance.assertions.enabled') ?: 'true').toString()
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
// `check` gains only the hermetic lanes. The Docker-backed ones stay opt-in for the reason above.
|
||||
tasks.named('check') {
|
||||
dependsOn 'mongoStableContractTest', 'verifyMongoTestLaneDisjointness'
|
||||
dependsOn 'mongoStableContractTest', 'verifyMongoTestLaneDisjointness',
|
||||
'verifyMongoReleaseContractLanes'
|
||||
}
|
||||
|
||||
// The tag exclusion above is a claim about two task configurations. This checks the claim against
|
||||
@@ -231,6 +195,55 @@ tasks.register('verifyMongoTestLaneDisjointness') {
|
||||
}
|
||||
}
|
||||
|
||||
// Splitting the two lanes moved every tagged contract out of `test`, and the release manifest kept
|
||||
// naming the lane it had left. `MongoReleaseEvidenceVerifier` resolves
|
||||
// `test-results/<task>/TEST-<className>.xml`, so a contract whose class now runs somewhere else
|
||||
// resolves to a file that will never exist: the Stable gate reports the transaction retry
|
||||
// invariants as evidence the run failed to produce, for a suite that ran them.
|
||||
//
|
||||
// Checked against the XML the lanes wrote rather than against a tag table, because a tag table here
|
||||
// would be a second copy of the selection above, and the copy is what drifted the first time.
|
||||
tasks.register('verifyMongoReleaseContractLanes') {
|
||||
group = 'verification'
|
||||
description = 'Fails when a blocking release contract names a lane that did not run its class.'
|
||||
dependsOn 'test', 'mongoStableContractTest'
|
||||
def manifest = rootProject.file('../src/config/mongodb/release-contracts.json')
|
||||
def hermeticLanes = ['test', 'mongoStableContractTest']
|
||||
def resultsRoot = layout.buildDirectory.dir('test-results')
|
||||
inputs.file(manifest)
|
||||
inputs.dir(resultsRoot)
|
||||
outputs.file(layout.buildDirectory.file('reports/mongo-release-contract-lanes.txt'))
|
||||
doLast {
|
||||
def contracts = new groovy.json.JsonSlurper().parse(manifest).contracts
|
||||
def checked = []
|
||||
def wrongLane = []
|
||||
contracts.findAll { hermeticLanes.contains(it.task) }.each { contract ->
|
||||
def results = resultsRoot.get().dir(contract.task).file(
|
||||
"TEST-${contract.className}.xml").asFile
|
||||
if (!results.isFile()) {
|
||||
wrongLane << "${contract.id} names lane '${contract.task}', which did not run " +
|
||||
"${contract.className}"
|
||||
return
|
||||
}
|
||||
def suite = new groovy.xml.XmlParser().parse(results)
|
||||
int executed = (suite.@tests as int) - (suite.@skipped as int)
|
||||
if (executed < contract.minimumExecuted) {
|
||||
wrongLane << "${contract.id} requires ${contract.minimumExecuted} executed test(s) " +
|
||||
"in '${contract.task}' and the lane ran ${executed}"
|
||||
}
|
||||
checked << contract.id
|
||||
}
|
||||
if (!wrongLane.isEmpty()) {
|
||||
throw new GradleException(
|
||||
'the Mongo release manifest points at lanes that cannot produce its evidence: ' +
|
||||
wrongLane.join('; '))
|
||||
}
|
||||
def report = outputs.files.singleFile
|
||||
report.parentFile.mkdirs()
|
||||
report.text = "hermetic release contracts verified: ${checked.join(', ')}\n"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('mongoStableContractTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Hermetic stable contract suite: manifests, guardrails, retry scopes, ' +
|
||||
@@ -257,95 +270,14 @@ tasks.register('mongoStableContractTest', Test) {
|
||||
// prerequisite for shrinking it: the `api` and `spi` packages are the surface an adopter is meant
|
||||
// to use, and everything else in this file is a candidate for becoming internal when the leaf is
|
||||
// split into capability artifacts. Until then the number cannot grow by accident.
|
||||
def mongoApiSurfaceFile = rootProject.file('../docs/architecture/mongo-api-surface.txt')
|
||||
|
||||
Closure<String> renderMongoApiSurface = {
|
||||
def sourceRoot = file('src/main/java')
|
||||
def typePattern = ~/(?m)^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(class|interface|enum|record|@interface)\s+(\w+)/
|
||||
def packagePattern = ~/(?m)^package\s+([\w.]+)\s*;/
|
||||
List<String> types = []
|
||||
sourceRoot.eachFileRecurse { candidate ->
|
||||
if (!candidate.isFile() || !candidate.name.endsWith('.java')) {
|
||||
return
|
||||
}
|
||||
String text = candidate.getText('UTF-8')
|
||||
def packageMatcher = packagePattern.matcher(text)
|
||||
if (!packageMatcher.find()) {
|
||||
return
|
||||
}
|
||||
String packageName = packageMatcher.group(1)
|
||||
def typeMatcher = typePattern.matcher(text)
|
||||
while (typeMatcher.find()) {
|
||||
types << "${packageName}.${typeMatcher.group(2)}".toString()
|
||||
}
|
||||
}
|
||||
types = types.unique().toSorted()
|
||||
String header =
|
||||
"# MongoDB leaf public API surface — every public top-level type in src/main/java.\n" +
|
||||
"# A public type in a single-jar leaf is reachable from every adopter's code, so\n" +
|
||||
"# additions are reviewed rather than discovered. `api` is the intended external\n" +
|
||||
"# surface; the rest is implementation that has not been moved under an internal\n" +
|
||||
"# root yet.\n" +
|
||||
"# Update only after review with:\n" +
|
||||
"# ./gradlew :adapter:outbound:persistence-mongo:updateMongoApiSurface -PapproveMongoApiSurfaceChange\n" +
|
||||
"# types: ${types.size()}\n"
|
||||
header + (types.isEmpty() ? '' : types.join('\n') + '\n')
|
||||
}
|
||||
|
||||
// The approval flag is read at configuration time and carried in, not fetched from `project`
|
||||
// inside doLast. Task.project at execution time is deprecated and fails under Gradle 10, and it is
|
||||
// incompatible with the configuration cache — which this build will need before it can adopt one.
|
||||
boolean mongoApiSurfaceUpdateApproved = project.hasProperty('approveMongoApiSurfaceChange')
|
||||
|
||||
tasks.register('verifyMongoApiSurface') {
|
||||
group = 'verification'
|
||||
description = 'Fails without mutation when the committed GraphQL public API surface drifts.'
|
||||
|
||||
doLast {
|
||||
if (mongoApiSurfaceUpdateApproved) {
|
||||
throw new GradleException(
|
||||
'verifyMongoApiSurface is read-only; use updateMongoApiSurface to record an ' +
|
||||
'approved change.')
|
||||
}
|
||||
String rendered = renderMongoApiSurface()
|
||||
if (!mongoApiSurfaceFile.isFile()) {
|
||||
throw new GradleException(
|
||||
"verifyMongoApiSurface: missing committed baseline ${mongoApiSurfaceFile}")
|
||||
}
|
||||
String committed = mongoApiSurfaceFile.getText('UTF-8')
|
||||
if (committed != rendered) {
|
||||
List<String> committedTypes = committed.readLines().findAll { !it.startsWith('#') }
|
||||
List<String> renderedTypes = rendered.readLines().findAll { !it.startsWith('#') }
|
||||
List<String> added = (renderedTypes - committedTypes).toSorted()
|
||||
List<String> removed = (committedTypes - renderedTypes).toSorted()
|
||||
throw new GradleException(
|
||||
"verifyMongoApiSurface: the public API surface changed.\n" +
|
||||
(added.isEmpty() ? '' : " added:\n " + added.join('\n ') + '\n') +
|
||||
(removed.isEmpty() ? '' : " removed:\n " + removed.join('\n ') + '\n') +
|
||||
"Review the change, then record it with:\n" +
|
||||
" ./gradlew :adapter:outbound:persistence-mongo:updateMongoApiSurface " +
|
||||
"-PapproveMongoApiSurfaceChange")
|
||||
}
|
||||
logger.lifecycle('verifyMongoApiSurface: OK — the committed public API surface is unchanged.')
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('updateMongoApiSurface') {
|
||||
group = 'verification'
|
||||
description = 'Rewrites the committed GraphQL public API surface baseline after review.'
|
||||
|
||||
doLast {
|
||||
if (!project.hasProperty('approveMongoApiSurfaceChange')) {
|
||||
throw new GradleException(
|
||||
'updateMongoApiSurface requires -PapproveMongoApiSurfaceChange: growing the ' +
|
||||
'public surface is a review decision, not a build step.')
|
||||
}
|
||||
mongoApiSurfaceFile.parentFile.mkdirs()
|
||||
mongoApiSurfaceFile.setText(renderMongoApiSurface(), 'UTF-8')
|
||||
logger.lifecycle("updateMongoApiSurface: wrote ${mongoApiSurfaceFile}")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('check') {
|
||||
dependsOn tasks.named('verifyMongoApiSurface')
|
||||
apiSurface {
|
||||
label = 'Mongo'
|
||||
baseline = rootProject.file('../docs/architecture/mongo-api-surface.txt')
|
||||
description = 'MongoDB leaf public API surface — every public top-level type in src/main/java.'
|
||||
rationale = [
|
||||
'A public type in a single-jar leaf is reachable from every adopter\'s code, so',
|
||||
'additions are reviewed rather than discovered. `api` is the intended external',
|
||||
'surface; the rest is implementation that has not been moved under an internal',
|
||||
'root yet.',
|
||||
]
|
||||
}
|
||||
|
||||
+4
@@ -23,6 +23,10 @@ public final class MongoAdvancedPromotionGate {
|
||||
evidence.require("stable-platform");
|
||||
evidence.require("actual-topology");
|
||||
evidence.require("security");
|
||||
// `migration` was in MongoAdvancedPromotionEvidence.REQUIRED and not here, so the gate demanded
|
||||
// five of the six categories it declares. A promotion could pass with no migration evidence at
|
||||
// all — which is the shape MNG-008 names: a gate that certifies more than it ran.
|
||||
evidence.require("migration");
|
||||
evidence.require("failure");
|
||||
evidence.require("runbook");
|
||||
}
|
||||
|
||||
+189
-1
@@ -119,6 +119,189 @@ public class MongoPlatformAutoConfiguration {
|
||||
return new DefaultMongoImperativeExecutor(consistency, collections, translator, observer);
|
||||
}
|
||||
|
||||
/**
|
||||
* The policies governing which fields a partial update may touch.
|
||||
*
|
||||
* <p>Empty unless a composition root declares one, and empty means every atomic and bulk
|
||||
* operation is refused rather than permitted: a collection whose modifiable fields nobody
|
||||
* declared has no field a partial update may touch.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicyRegistry
|
||||
mongoAtomicPolicyRegistry() {
|
||||
return dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicyRegistry
|
||||
.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* The typed single-document update path.
|
||||
*
|
||||
* <p>Declared by no configuration until now. The template, its policy registry and the whole
|
||||
* {@code AtomicFilter}/{@code AtomicUpdate} algebra shipped in the jar with passing unit tests
|
||||
* that constructed them directly, so a deployment that switched the platform on had no way to
|
||||
* reach the one path that enforces the field policy — the design's answer to free-form BSON
|
||||
* updates was unreachable, and the free-form path was not.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperationsTemplate
|
||||
mongoAtomicOperations(
|
||||
DefaultMongoImperativeExecutor executor,
|
||||
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicyRegistry
|
||||
policies) {
|
||||
return new dev.caskeleton.adapter.outbound.mongo.imperative.atomic
|
||||
.MongoAtomicOperationsTemplate(executor, policies);
|
||||
}
|
||||
|
||||
/**
|
||||
* The batch update path, governed by the same policies as the single-document one.
|
||||
*
|
||||
* <p>Same story as the atomic template, with one addition: the bulk executor could be built
|
||||
* without policies at all, so wiring it without its registry would have re-created the bypass it
|
||||
* was fixed for.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkExecutor mongoBulkExecutor(
|
||||
DefaultMongoImperativeExecutor executor,
|
||||
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicyRegistry policies) {
|
||||
return new dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkExecutor(
|
||||
executor, policies);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reactive execution path, when a deployment has a reactive template.
|
||||
*
|
||||
* <p>The reactive half shipped and was wired by nothing. {@code DefaultReactiveMongoExecutor},
|
||||
* the reactive consistency binder and the reactive session factory were beans in no
|
||||
* configuration, so a deployment that switched the platform on got the blocking path and a set of
|
||||
* reactive classes that no configuration could construct — including the {@code .timeout(...)}
|
||||
* translation and the result-budget cursor guard the platform was credited with.
|
||||
*
|
||||
* <p>Nested and conditioned on {@code ReactiveMongoTemplate} being present *and* declared as a
|
||||
* bean. The class is on this leaf's compile classpath either way, so a class condition alone
|
||||
* would try to build the reactive path in a servlet-only deployment that has no reactive
|
||||
* template, and fail its startup for a capability it never asked for.
|
||||
*/
|
||||
@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnClass(org.springframework.data.mongodb.core.ReactiveMongoTemplate.class)
|
||||
@ConditionalOnBean(org.springframework.data.mongodb.core.ReactiveMongoTemplate.class)
|
||||
public static class ReactiveExecution {
|
||||
|
||||
/**
|
||||
* Binds read and write settings per profile on the reactive template.
|
||||
*
|
||||
* @param template the reactive template
|
||||
* @param registry the consistency registry
|
||||
* @return the reactive binder
|
||||
*/
|
||||
/**
|
||||
* The driver-facing half of a change stream subscription.
|
||||
*
|
||||
* <p>No production code opened a change stream at all. The checkpoint store, the resume
|
||||
* position, the recovery policy, the invalidate conversion, the ordered pipeline and the
|
||||
* projector all shipped with passing unit tests and a deployment had no way to run any of them,
|
||||
* so the at-least-once consumer contract the design describes was unreachable.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public dev.caskeleton.adapter.outbound.mongo.changestream.consumer.MongoChangeStreamSource
|
||||
mongoChangeStreamSource(
|
||||
org.springframework.data.mongodb.core.ReactiveMongoTemplate template,
|
||||
MongoCollectionProfileRegistry collections) {
|
||||
return new dev.caskeleton.adapter.outbound.mongo.changestream.consumer
|
||||
.SpringReactiveChangeStreamSource(template, collections);
|
||||
}
|
||||
|
||||
/**
|
||||
* One subscription, wired end to end.
|
||||
*
|
||||
* <p>Conditional on the pieces only a deployment can supply — what to project, where to record
|
||||
* that it was projected, how to protect the stored token, and which collection to watch. The
|
||||
* platform cannot invent a projector, so a consumer that appears without one would be a
|
||||
* subscription reading a stream into nothing.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@org.springframework.boot.autoconfigure.condition.ConditionalOnBean({
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription.class,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore.class,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeTokenCodec.class,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjector.class,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeDeduplicationStore
|
||||
.class
|
||||
})
|
||||
public dev.caskeleton.adapter.outbound.mongo.changestream.consumer
|
||||
.ReactiveMongoChangeStreamConsumer
|
||||
reactiveMongoChangeStreamConsumer(
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription
|
||||
subscription,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.MongoChangeStreamSource
|
||||
source,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore
|
||||
checkpoints,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeTokenCodec tokens,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjector
|
||||
projector,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.projector
|
||||
.MongoChangeDeduplicationStore
|
||||
deduplication) {
|
||||
return new dev.caskeleton.adapter.outbound.mongo.changestream.consumer
|
||||
.ReactiveMongoChangeStreamConsumer(
|
||||
subscription,
|
||||
source,
|
||||
checkpoints,
|
||||
tokens,
|
||||
new dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamPipeline(
|
||||
new dev.caskeleton.adapter.outbound.mongo.changestream.projector
|
||||
.MongoChangeStreamRunner(projector, deduplication, checkpoints)),
|
||||
new dev.caskeleton.adapter.outbound.mongo.changestream.recovery
|
||||
.MongoChangeStreamRecoveryPolicy(),
|
||||
new dev.caskeleton.adapter.outbound.mongo.changestream.recovery
|
||||
.MongoInvalidateRecovery());
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoConsistencyBinder
|
||||
reactiveMongoConsistencyBinder(
|
||||
org.springframework.data.mongodb.core.ReactiveMongoTemplate template,
|
||||
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyRegistry registry,
|
||||
org.springframework.context.ApplicationContext applicationContext) {
|
||||
// The same support contract the imperative binder gets. Without it a document written through
|
||||
// the reactive executor skips auditing and BeforeConvertCallback while the same document
|
||||
// written through an ordinary reactive repository does not — two documents, one mapping.
|
||||
return new dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoConsistencyBinder(
|
||||
template,
|
||||
registry,
|
||||
dev.caskeleton.adapter.outbound.mongo.imperative.MongoTemplateSupportContract.inContext(
|
||||
applicationContext));
|
||||
}
|
||||
|
||||
/**
|
||||
* The single reactive execution path.
|
||||
*
|
||||
* @param consistency the reactive binder
|
||||
* @param collections the collection profile registry
|
||||
* @param translator the failure translator
|
||||
* @param observer the operation observer
|
||||
* @return the reactive executor
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public dev.caskeleton.adapter.outbound.mongo.reactive.DefaultReactiveMongoExecutor
|
||||
mongoReactiveExecutor(
|
||||
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoConsistencyBinder
|
||||
consistency,
|
||||
MongoCollectionProfileRegistry collections,
|
||||
MongoFailureTranslator translator,
|
||||
MongoOperationObserver observer) {
|
||||
return new dev.caskeleton.adapter.outbound.mongo.reactive.DefaultReactiveMongoExecutor(
|
||||
consistency, collections, translator, observer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Lets a caller tighten a registered budget, never loosen it. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@@ -244,12 +427,17 @@ public class MongoPlatformAutoConfiguration {
|
||||
*
|
||||
* <p>Registered as a bean so an application's existing actuator policy can expose it. Whether it
|
||||
* is exposed stays that policy's decision; being constructible only in tests was not a policy.
|
||||
*
|
||||
* <p>The secondary count comes from the probe and the requirement from the settings. This method
|
||||
* used to pass a literal zero for the first and let the indicator default the second, so the
|
||||
* health detail an operator reads said "0 of 2 secondaries, degraded" on every replica set the
|
||||
* platform ever ran against, healthy or not — a fixed answer presented as a measurement.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@ConditionalOnBean(MongoTopologyProbe.class)
|
||||
public MongoPlatformHealthIndicator mongoPlatformHealthIndicator(
|
||||
MongoTopologyProbe probe, MongoPlatformSettings properties) {
|
||||
return new MongoPlatformHealthIndicator(probe, properties, 0);
|
||||
return new MongoPlatformHealthIndicator(probe, properties);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-30
@@ -22,38 +22,19 @@ public final class MongoPlatformHealthIndicator {
|
||||
|
||||
private final MongoPlatformSettings properties;
|
||||
|
||||
private final int availableSecondaries;
|
||||
|
||||
private final int requiredSecondaries;
|
||||
|
||||
/** The default requirement: a replica set that can still elect and still acknowledge majority. */
|
||||
public static final int DEFAULT_REQUIRED_SECONDARIES = 2;
|
||||
|
||||
public MongoPlatformHealthIndicator(
|
||||
MongoTopologyProbe probe, MongoPlatformSettings properties, int availableSecondaries) {
|
||||
this(probe, properties, availableSecondaries, DEFAULT_REQUIRED_SECONDARIES);
|
||||
}
|
||||
|
||||
/**
|
||||
* A health view with an explicit secondary requirement.
|
||||
* A health view over one probe and the settings that say what this deployment needs.
|
||||
*
|
||||
* <p>The threshold used to be the literal {@code 2} for every non-standalone deployment. A
|
||||
* three-member set with one arbiter has one secondary and is healthy; a five-member set with two
|
||||
* secondaries is one failure away from losing majority writes and reported as fine. One constant
|
||||
* cannot describe both, so the number is configuration and the deployment states it.
|
||||
* <p>Both numbers behind {@link #degradedSecondaryAvailability()} arrive through those two, and
|
||||
* neither is a parameter of this constructor, because both used to be supplied at the call site
|
||||
* and both were wrong there. The observed count was a literal {@code 0} in the bean method, so
|
||||
* every replica set reported every secondary missing; the requirement was a literal {@code 2} for
|
||||
* every deployment, so a three-member set with an arbiter — one secondary, and healthy — was
|
||||
* reported degraded for as long as it ran.
|
||||
*/
|
||||
public MongoPlatformHealthIndicator(
|
||||
MongoTopologyProbe probe,
|
||||
MongoPlatformSettings properties,
|
||||
int availableSecondaries,
|
||||
int requiredSecondaries) {
|
||||
public MongoPlatformHealthIndicator(MongoTopologyProbe probe, MongoPlatformSettings properties) {
|
||||
this.probe = Objects.requireNonNull(probe, "probe");
|
||||
this.properties = Objects.requireNonNull(properties, "properties");
|
||||
this.availableSecondaries = availableSecondaries;
|
||||
if (requiredSecondaries < 0) {
|
||||
throw new IllegalArgumentException("the required secondary count must not be negative");
|
||||
}
|
||||
this.requiredSecondaries = requiredSecondaries;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +68,7 @@ public final class MongoPlatformHealthIndicator {
|
||||
*/
|
||||
public boolean degradedSecondaryAvailability() {
|
||||
return probe.observedTopology() != MongoTopology.STANDALONE
|
||||
&& availableSecondaries < requiredSecondaries;
|
||||
&& probe.availableSecondaries() < properties.requiredSecondaries();
|
||||
}
|
||||
|
||||
/** The health detail map, containing only bounded, non-sensitive values. */
|
||||
@@ -97,8 +78,8 @@ public final class MongoPlatformHealthIndicator {
|
||||
details.put("serverVersion", probe.serverVersion());
|
||||
details.put("topologyMismatch", topologyMismatch());
|
||||
details.put("degradedSecondaryAvailability", degradedSecondaryAvailability());
|
||||
details.put("availableSecondaries", availableSecondaries);
|
||||
details.put("requiredSecondaries", requiredSecondaries);
|
||||
details.put("availableSecondaries", probe.availableSecondaries());
|
||||
details.put("requiredSecondaries", properties.requiredSecondaries());
|
||||
details.put("profiles", properties.profiles().keySet());
|
||||
return Map.copyOf(details);
|
||||
}
|
||||
|
||||
+26
-2
@@ -25,7 +25,20 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
*/
|
||||
@ConfigurationProperties("ca-skeleton.persistence-mongo.platform")
|
||||
public record MongoPlatformSettings(
|
||||
Map<String, MongoProfileProperties> profiles, boolean transactions, boolean changeStreams) {
|
||||
Map<String, MongoProfileProperties> profiles,
|
||||
boolean transactions,
|
||||
boolean changeStreams,
|
||||
Integer requiredSecondaries) {
|
||||
|
||||
/**
|
||||
* The secondary count assumed when a deployment states none.
|
||||
*
|
||||
* <p>Two, because that is the smallest number that survives losing one and still acknowledges a
|
||||
* majority write. It is a starting point and not a description: a three-member set with an
|
||||
* arbiter has one secondary and is healthy, and a five-member set with two is one failure from
|
||||
* losing majority writes. Both need the deployment to say so.
|
||||
*/
|
||||
public static final int DEFAULT_REQUIRED_SECONDARIES = 2;
|
||||
|
||||
public MongoPlatformSettings {
|
||||
// Absent rather than empty is the normal case: a deployment that has opted the module in but
|
||||
@@ -40,11 +53,22 @@ public record MongoPlatformSettings(
|
||||
// zero beans, zero threads, and a `true` that cannot be honoured never becomes one that looks
|
||||
// honoured.
|
||||
changeStreams = false;
|
||||
// Absent means the platform default, and a negative count is refused rather than clamped: a
|
||||
// requirement of -1 is not a lenient requirement, it is a typo, and reading it as "no
|
||||
// secondaries needed" is how a health view comes to report a cluster it never checked.
|
||||
if (requiredSecondaries == null) {
|
||||
requiredSecondaries = DEFAULT_REQUIRED_SECONDARIES;
|
||||
}
|
||||
if (requiredSecondaries < 0) {
|
||||
throw MongoOperationRejectedException.of(
|
||||
"config.secondaries",
|
||||
"the required secondary count must not be negative; " + requiredSecondaries + " is");
|
||||
}
|
||||
}
|
||||
|
||||
/** An empty configuration, for a deployment that has not opted the platform in. */
|
||||
public static MongoPlatformSettings empty() {
|
||||
return new MongoPlatformSettings(Map.of(), false, false);
|
||||
return new MongoPlatformSettings(Map.of(), false, false, null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
package dev.caskeleton.adapter.outbound.mongo.autoconfigure;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The evidence categories a Stable release must produce (design §30, Task 50).
|
||||
*
|
||||
* <p>Named categories rather than "all tests pass", because a suite can be green and still be
|
||||
* missing a lane. Requiring a category to be present makes the absence of failover or compatibility
|
||||
* evidence a failure rather than a silence.
|
||||
*/
|
||||
public record MongoStableReleaseEvidence(Set<String> categories) {
|
||||
|
||||
/** Every category the Stable gate requires. */
|
||||
public static final Set<String> REQUIRED =
|
||||
Set.of(
|
||||
"mapping",
|
||||
"transaction",
|
||||
"migration",
|
||||
"change-stream",
|
||||
"security",
|
||||
"failover",
|
||||
"performance",
|
||||
"compatibility");
|
||||
|
||||
public MongoStableReleaseEvidence {
|
||||
Objects.requireNonNull(categories, "categories");
|
||||
categories = Set.copyOf(categories);
|
||||
}
|
||||
|
||||
/** Evidence with nothing recorded. */
|
||||
public static MongoStableReleaseEvidence empty() {
|
||||
return new MongoStableReleaseEvidence(Set.of());
|
||||
}
|
||||
|
||||
/** The evidence a complete release run produces. */
|
||||
public static MongoStableReleaseEvidence complete() {
|
||||
return new MongoStableReleaseEvidence(REQUIRED);
|
||||
}
|
||||
|
||||
/** Returns a copy with one more category recorded. */
|
||||
public MongoStableReleaseEvidence with(String category) {
|
||||
Set<String> updated = new LinkedHashSet<>(categories);
|
||||
updated.add(Objects.requireNonNull(category, "category"));
|
||||
return new MongoStableReleaseEvidence(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts one category is present.
|
||||
*
|
||||
* @throws IllegalStateException naming the missing category
|
||||
*/
|
||||
public void require(String category) {
|
||||
if (!categories.contains(Objects.requireNonNull(category, "category"))) {
|
||||
throw new IllegalStateException(
|
||||
"the MongoDB Stable release gate is missing '"
|
||||
+ category
|
||||
+ "' evidence; the required categories are "
|
||||
+ REQUIRED);
|
||||
}
|
||||
}
|
||||
|
||||
/** The categories that are still missing. */
|
||||
public Set<String> missing() {
|
||||
Set<String> missing = new LinkedHashSet<>(REQUIRED);
|
||||
missing.removeAll(categories);
|
||||
return Set.copyOf(missing);
|
||||
}
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
package dev.caskeleton.adapter.outbound.mongo.autoconfigure;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* The single check that decides whether the Stable platform may ship (design §30, Task 50).
|
||||
*
|
||||
* <p>Every category is required, including the two teams most often skip. Compatibility, because it
|
||||
* only fails for the customers still on the older server. Failover, because it only fails during an
|
||||
* election — which is exactly when nobody is reading test reports.
|
||||
*/
|
||||
public final class MongoStableReleaseGate {
|
||||
|
||||
/**
|
||||
* Verifies a release run.
|
||||
*
|
||||
* @throws IllegalStateException naming the first missing category
|
||||
*/
|
||||
public void verify(MongoStableReleaseEvidence evidence) {
|
||||
Objects.requireNonNull(evidence, "evidence");
|
||||
evidence.require("mapping");
|
||||
evidence.require("transaction");
|
||||
evidence.require("migration");
|
||||
evidence.require("change-stream");
|
||||
evidence.require("security");
|
||||
evidence.require("failover");
|
||||
evidence.require("performance");
|
||||
evidence.require("compatibility");
|
||||
}
|
||||
|
||||
/** True when the release run produced every required category. */
|
||||
public boolean passes(MongoStableReleaseEvidence evidence) {
|
||||
return Objects.requireNonNull(evidence, "evidence").missing().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an Advanced capability may be part of this release.
|
||||
*
|
||||
* <p>Always false. Advanced capabilities have their own promotion gate with their own evidence,
|
||||
* and folding them into the Stable gate would mean shipping something whose real topology was
|
||||
* never exercised.
|
||||
*/
|
||||
public boolean includesAdvancedCapabilities() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+29
-1
@@ -16,6 +16,10 @@ import java.util.Objects;
|
||||
* actually connected to. The startup validator compares the two, because "we configured a replica
|
||||
* set" and "we are talking to one" are different claims and only the second one determines whether
|
||||
* transactions work.
|
||||
*
|
||||
* <p>Everything here is an observation. Nothing on this class has a default, because a default is
|
||||
* an answer about a cluster nobody looked at, and the health view that reads these values reports
|
||||
* them to an operator as fact.
|
||||
*/
|
||||
public final class MongoTopologyProbe {
|
||||
|
||||
@@ -23,9 +27,21 @@ public final class MongoTopologyProbe {
|
||||
|
||||
private final String serverVersion;
|
||||
|
||||
public MongoTopologyProbe(MongoTopology observedTopology, String serverVersion) {
|
||||
private final int availableSecondaries;
|
||||
|
||||
/**
|
||||
* @param observedTopology the topology the client connected to
|
||||
* @param serverVersion the version the connected server reports
|
||||
* @param availableSecondaries how many data-bearing secondaries the client can currently see
|
||||
*/
|
||||
public MongoTopologyProbe(
|
||||
MongoTopology observedTopology, String serverVersion, int availableSecondaries) {
|
||||
this.observedTopology = Objects.requireNonNull(observedTopology, "observedTopology");
|
||||
this.serverVersion = Objects.requireNonNull(serverVersion, "serverVersion");
|
||||
if (availableSecondaries < 0) {
|
||||
throw new IllegalArgumentException("the observed secondary count must not be negative");
|
||||
}
|
||||
this.availableSecondaries = availableSecondaries;
|
||||
}
|
||||
|
||||
/** The topology this client is actually connected to. */
|
||||
@@ -38,6 +54,18 @@ public final class MongoTopologyProbe {
|
||||
return serverVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many data-bearing secondaries this client can currently see.
|
||||
*
|
||||
* <p>Part of the probe rather than a number the health bean is handed separately, because it is
|
||||
* an observation of the same cluster description that produced the topology, and a caller who has
|
||||
* one has the other. It used to be neither: the health bean was constructed with a literal zero,
|
||||
* so a healthy five-member set and a set with every secondary down produced identical output.
|
||||
*/
|
||||
public int availableSecondaries() {
|
||||
return availableSecondaries;
|
||||
}
|
||||
|
||||
/** The capabilities this topology and version support. */
|
||||
public MongoCapabilitySet capabilities() {
|
||||
List<MongoCapabilitySupport> supports = new ArrayList<>();
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.adapter.outbound.mongo.changestream;
|
||||
|
||||
import org.bson.BsonDocument;
|
||||
|
||||
/**
|
||||
* Turns a driver resume token into a durable checkpoint and back (design §20.2).
|
||||
*
|
||||
* <p>A resume token encodes the cluster time and the document key of the last event, so it is
|
||||
* production write timing and production identifiers in one opaque-looking string. The checkpoint
|
||||
* holds it as ciphertext for that reason, and this is the boundary where the conversion happens:
|
||||
* without it, a consumer wanting to resume would have to decode the stored bytes itself, which is
|
||||
* how "the checkpoint is encrypted" quietly becomes "the checkpoint is base64".
|
||||
*
|
||||
* <p>There is no default implementation. How a deployment protects the token — which key, which
|
||||
* rotation — is not something this leaf can decide on its behalf, and a built-in that merely
|
||||
* encoded would be worse than none: it would satisfy the type and none of the reason for it.
|
||||
*/
|
||||
public interface MongoResumeTokenCodec {
|
||||
|
||||
/**
|
||||
* Stores a token as a checkpoint.
|
||||
*
|
||||
* @param subscriptionProfile the subscription the position belongs to
|
||||
* @param token the driver's resume token
|
||||
* @param position which resume option the token must be replayed with
|
||||
* @return the durable checkpoint
|
||||
*/
|
||||
MongoResumeCheckpoint encode(
|
||||
String subscriptionProfile, BsonDocument token, MongoResumePosition position);
|
||||
|
||||
/**
|
||||
* Recovers the token a checkpoint stands for.
|
||||
*
|
||||
* @param checkpoint the stored checkpoint
|
||||
* @return the driver's resume token
|
||||
*/
|
||||
BsonDocument decode(MongoResumeCheckpoint checkpoint);
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.outbound.mongo.changestream.consumer;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription;
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition;
|
||||
import java.util.Optional;
|
||||
import org.bson.BsonDocument;
|
||||
import org.springframework.data.mongodb.core.ChangeStreamEvent;
|
||||
import org.springframework.data.mongodb.core.query.Query;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
/**
|
||||
* Opens the driver's change stream at a stated position.
|
||||
*
|
||||
* <p>A seam rather than a call, because everything worth testing about a change stream consumer —
|
||||
* what it resumes from, what it does with an invalidate, what it does when history is lost — is
|
||||
* about the sequence around the stream and not about the stream itself. Testing that sequence
|
||||
* against a live replica set turns a lifecycle assertion into a container.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface MongoChangeStreamSource {
|
||||
|
||||
/**
|
||||
* Opens the stream.
|
||||
*
|
||||
* @param subscription the subscription's configuration
|
||||
* @param resumeFrom the position to resume from, absent when starting fresh
|
||||
* @return the events, in the order the server produced them
|
||||
*/
|
||||
Flux<ChangeStreamEvent<org.bson.Document>> open(
|
||||
MongoChangeStreamSubscription subscription, Optional<ResumeFrom> resumeFrom);
|
||||
|
||||
/**
|
||||
* A stored position and the option it must be replayed with.
|
||||
*
|
||||
* <p>The two travel together because they are not independent: {@code resumeAfter} refuses a
|
||||
* token that came from an invalidate event, so a position without its option cannot be replayed
|
||||
* in exactly the case where replaying matters.
|
||||
*
|
||||
* @param token the driver's resume token
|
||||
* @param position the resume option the token was recorded under
|
||||
*/
|
||||
record ResumeFrom(BsonDocument token, MongoResumePosition position) {
|
||||
|
||||
public ResumeFrom {
|
||||
java.util.Objects.requireNonNull(token, "token");
|
||||
java.util.Objects.requireNonNull(position, "position");
|
||||
}
|
||||
}
|
||||
|
||||
/** The query a subscription watches; unfiltered unless a deployment narrows it. */
|
||||
static Query everything() {
|
||||
return new Query();
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package dev.caskeleton.adapter.outbound.mongo.changestream.consumer;
|
||||
|
||||
import com.mongodb.client.model.changestream.OperationType;
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity;
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamPipeline;
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamState;
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription;
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoClusterTime;
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpoint;
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore;
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition;
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeTokenCodec;
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjectionResult;
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryDecision;
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryPolicy;
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoInvalidateRecovery;
|
||||
import dev.caskeleton.adapter.outbound.mongo.failure.MongoDriverFailureView;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bson.BsonDocument;
|
||||
import org.springframework.data.mongodb.core.ChangeStreamEvent;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* One change stream subscription, from stored position to stored position (design §20).
|
||||
*
|
||||
* <p>Every part of this lifecycle existed and nothing joined them. The checkpoint store, the resume
|
||||
* position, the recovery policy, the invalidate conversion, the ordered pipeline and the projector
|
||||
* were separate classes with passing unit tests, and no production code opened a change stream at
|
||||
* all — so the at-least-once consumer contract the design describes was a set of components a
|
||||
* deployment had no way to run. The pieces were correct and the capability did not exist.
|
||||
*
|
||||
* <p>What this owns is the order: load the checkpoint, choose the resume option it was recorded
|
||||
* under, open the stream there, hand the events to the pipeline one at a time, and store the new
|
||||
* position only after a projection has succeeded. An invalidate is converted to a {@code
|
||||
* startAfter} position at the moment it arrives, because afterwards the distinction cannot be
|
||||
* recovered. A lost history stops the subscription rather than restarting it from now, which would
|
||||
* produce a projection that is missing an unknown range of changes and reports itself as healthy.
|
||||
*/
|
||||
public final class ReactiveMongoChangeStreamConsumer {
|
||||
|
||||
private final MongoChangeStreamSubscription subscription;
|
||||
|
||||
private final MongoChangeStreamSource source;
|
||||
|
||||
private final MongoResumeCheckpointStore checkpoints;
|
||||
|
||||
private final MongoResumeTokenCodec tokens;
|
||||
|
||||
private final MongoChangeStreamPipeline pipeline;
|
||||
|
||||
private final MongoChangeStreamRecoveryPolicy recovery;
|
||||
|
||||
private final MongoInvalidateRecovery invalidates;
|
||||
|
||||
private final AtomicReference<MongoChangeStreamState> state =
|
||||
new AtomicReference<>(MongoChangeStreamState.STOPPED);
|
||||
|
||||
private final AtomicReference<String> requiredRunbook = new AtomicReference<>("");
|
||||
|
||||
public ReactiveMongoChangeStreamConsumer(
|
||||
MongoChangeStreamSubscription subscription,
|
||||
MongoChangeStreamSource source,
|
||||
MongoResumeCheckpointStore checkpoints,
|
||||
MongoResumeTokenCodec tokens,
|
||||
MongoChangeStreamPipeline pipeline,
|
||||
MongoChangeStreamRecoveryPolicy recovery,
|
||||
MongoInvalidateRecovery invalidates) {
|
||||
this.subscription = Objects.requireNonNull(subscription, "subscription");
|
||||
this.source = Objects.requireNonNull(source, "source");
|
||||
this.checkpoints = Objects.requireNonNull(checkpoints, "checkpoints");
|
||||
this.tokens = Objects.requireNonNull(tokens, "tokens");
|
||||
this.pipeline = Objects.requireNonNull(pipeline, "pipeline");
|
||||
this.recovery = Objects.requireNonNull(recovery, "recovery");
|
||||
this.invalidates = Objects.requireNonNull(invalidates, "invalidates");
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the subscription until it is stopped or a failure the platform will not resume from.
|
||||
*
|
||||
* @return the projection result of every event this consumer handled
|
||||
*/
|
||||
public Flux<MongoChangeProjectionResult> run() {
|
||||
return Mono.fromRunnable(() -> state.set(MongoChangeStreamState.STARTING))
|
||||
.thenMany(Flux.defer(this::openAndConsume))
|
||||
.onErrorResume(this::recoverFrom);
|
||||
}
|
||||
|
||||
/** The subscription's current lifecycle state. */
|
||||
public MongoChangeStreamState state() {
|
||||
return state.get();
|
||||
}
|
||||
|
||||
/** The runbook an operator needs when this subscription has stopped, empty while it runs. */
|
||||
public String requiredRunbook() {
|
||||
return requiredRunbook.get();
|
||||
}
|
||||
|
||||
private Flux<MongoChangeProjectionResult> openAndConsume() {
|
||||
return checkpoints
|
||||
.load(subscription.subscriptionProfile())
|
||||
.map(this::resumeFrom)
|
||||
.flatMapMany(
|
||||
resumeFrom -> {
|
||||
state.set(MongoChangeStreamState.RUNNING);
|
||||
return pipeline.process(source.open(subscription, resumeFrom).flatMap(this::toEvent));
|
||||
});
|
||||
}
|
||||
|
||||
private Optional<MongoChangeStreamSource.ResumeFrom> resumeFrom(
|
||||
Optional<MongoResumeCheckpoint> stored) {
|
||||
return stored.map(
|
||||
checkpoint -> {
|
||||
// The option the position was recorded under, not the option this call happens to want:
|
||||
// replaying an invalidate token with resumeAfter is refused by the server, and the
|
||||
// failure it produces names neither the subscription nor the reason.
|
||||
invalidates.requireCorrectResumeOption(checkpoint, checkpoint.position());
|
||||
return new MongoChangeStreamSource.ResumeFrom(
|
||||
tokens.decode(checkpoint), checkpoint.position());
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<MongoChangeStreamPipeline.MongoChangeEvent> toEvent(
|
||||
ChangeStreamEvent<org.bson.Document> event) {
|
||||
BsonDocument token =
|
||||
event.getResumeToken() == null ? null : event.getResumeToken().asDocument();
|
||||
if (token == null) {
|
||||
return Mono.error(
|
||||
new IllegalStateException(
|
||||
"a change event arrived without a resume token; there is no position to store and"
|
||||
+ " continuing would advance the subscription past an event it cannot resume at"));
|
||||
}
|
||||
// An invalidate closes the stream, and its token is the one thing `resumeAfter` refuses. The
|
||||
// option is decided here, while the event says which it is; by restart time the stored token
|
||||
// looks like any other and the distinction is gone.
|
||||
MongoResumePosition position =
|
||||
event.getOperationType() == OperationType.INVALIDATE
|
||||
? MongoResumePosition.START_AFTER
|
||||
: MongoResumePosition.RESUME_AFTER;
|
||||
MongoResumeCheckpoint checkpoint =
|
||||
tokens.encode(subscription.subscriptionProfile(), token, position);
|
||||
return Mono.just(
|
||||
new MongoChangeStreamPipeline.MongoChangeEvent(
|
||||
identityOf(event), rawOf(event), checkpoint, clusterTimeOf(event)));
|
||||
}
|
||||
|
||||
private MongoChangeEventIdentity identityOf(ChangeStreamEvent<org.bson.Document> event) {
|
||||
var raw = event.getRaw();
|
||||
String namespace =
|
||||
(event.getDatabaseName() == null ? "" : event.getDatabaseName())
|
||||
+ '.'
|
||||
+ (event.getCollectionName() == null ? "" : event.getCollectionName());
|
||||
String documentKey =
|
||||
raw == null || raw.getDocumentKey() == null ? "" : raw.getDocumentKey().toJson();
|
||||
// The transaction coordinates are what separate two updates to one document inside one
|
||||
// transaction: without them the two events share cluster time, namespace, key and operation,
|
||||
// and the second is discarded as a redelivery of the first.
|
||||
String withinTransaction =
|
||||
raw == null || raw.getTxnNumber() == null
|
||||
? ""
|
||||
: raw.getTxnNumber().getValue()
|
||||
+ ":"
|
||||
+ (raw.getLsid() == null ? "" : raw.getLsid().toJson());
|
||||
return MongoChangeEventIdentity.of(
|
||||
clusterTimeOf(event).toString(),
|
||||
namespace,
|
||||
documentKey,
|
||||
event.getOperationType() == null ? "unknown" : event.getOperationType().getValue(),
|
||||
withinTransaction);
|
||||
}
|
||||
|
||||
private static BsonDocument rawOf(ChangeStreamEvent<org.bson.Document> event) {
|
||||
var raw = event.getRaw();
|
||||
return raw == null || raw.getFullDocument() == null
|
||||
? new BsonDocument()
|
||||
: raw.getFullDocument().toBsonDocument();
|
||||
}
|
||||
|
||||
private static MongoClusterTime clusterTimeOf(ChangeStreamEvent<org.bson.Document> event) {
|
||||
var timestamp = event.getBsonTimestamp();
|
||||
return timestamp == null
|
||||
? new MongoClusterTime(0, 0)
|
||||
: new MongoClusterTime(timestamp.getTime(), timestamp.getInc());
|
||||
}
|
||||
|
||||
private Flux<MongoChangeProjectionResult> recoverFrom(Throwable failure) {
|
||||
MongoChangeStreamRecoveryDecision decision = decisionFor(failure);
|
||||
if (!decision.autoResume()) {
|
||||
state.set(decision.state());
|
||||
requiredRunbook.set(decision.requiredRunbook());
|
||||
return Flux.error(failure);
|
||||
}
|
||||
state.set(MongoChangeStreamState.RESUMING);
|
||||
// Reopened from the stored checkpoint rather than from where the stream stopped: the last
|
||||
// position that was written is the last event a projection completed for, and anything after
|
||||
// it must be delivered again.
|
||||
return Flux.defer(this::openAndConsume).onErrorResume(this::haltOn);
|
||||
}
|
||||
|
||||
private Flux<MongoChangeProjectionResult> haltOn(Throwable failure) {
|
||||
MongoChangeStreamRecoveryDecision decision = decisionFor(failure);
|
||||
state.set(decision.autoResume() ? MongoChangeStreamState.FAILED : decision.state());
|
||||
requiredRunbook.set(
|
||||
decision.requiredRunbook().isBlank()
|
||||
? MongoChangeStreamRecoveryPolicy.FAILURE_RUNBOOK
|
||||
: decision.requiredRunbook());
|
||||
return Flux.error(failure);
|
||||
}
|
||||
|
||||
private MongoChangeStreamRecoveryDecision decisionFor(Throwable failure) {
|
||||
if (failure instanceof com.mongodb.MongoException driverFailure) {
|
||||
return recovery.onFailure(MongoDriverFailureView.from(driverFailure));
|
||||
}
|
||||
// Anything that is not a driver failure came from the projection or the checkpoint store, and
|
||||
// resuming a stream does not fix either.
|
||||
return MongoChangeStreamRecoveryDecision.halt(
|
||||
MongoChangeStreamState.FAILED, MongoChangeStreamRecoveryPolicy.FAILURE_RUNBOOK);
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package dev.caskeleton.adapter.outbound.mongo.changestream.consumer;
|
||||
|
||||
import com.mongodb.client.model.changestream.FullDocument;
|
||||
import com.mongodb.client.model.changestream.FullDocumentBeforeChange;
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription;
|
||||
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition;
|
||||
import dev.caskeleton.adapter.outbound.mongo.imperative.MongoCollectionProfileRegistry;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.mongodb.core.ChangeStreamEvent;
|
||||
import org.springframework.data.mongodb.core.ChangeStreamOptions;
|
||||
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
/**
|
||||
* The driver-facing half of a change stream subscription.
|
||||
*
|
||||
* <p>Deliberately thin: it turns a stored position into the driver's options and opens the cursor.
|
||||
* Everything that decides what happens next lives in the consumer, so the lifecycle can be
|
||||
* exercised without a replica set and this class has nothing left to get wrong except the mapping
|
||||
* it is named for.
|
||||
*
|
||||
* <p>The collection is resolved through the profile registry rather than named directly, so a
|
||||
* subscription cannot watch a collection the platform does not know about — which is also the only
|
||||
* way the physical name stays a deployment decision.
|
||||
*/
|
||||
public final class SpringReactiveChangeStreamSource implements MongoChangeStreamSource {
|
||||
|
||||
private final ReactiveMongoTemplate template;
|
||||
|
||||
private final MongoCollectionProfileRegistry collections;
|
||||
|
||||
public SpringReactiveChangeStreamSource(
|
||||
ReactiveMongoTemplate template, MongoCollectionProfileRegistry collections) {
|
||||
this.template = Objects.requireNonNull(template, "template");
|
||||
this.collections = Objects.requireNonNull(collections, "collections");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<ChangeStreamEvent<org.bson.Document>> open(
|
||||
MongoChangeStreamSubscription subscription, Optional<ResumeFrom> resumeFrom) {
|
||||
Objects.requireNonNull(subscription, "subscription");
|
||||
Objects.requireNonNull(resumeFrom, "resumeFrom");
|
||||
|
||||
return template
|
||||
.changeStream(org.bson.Document.class)
|
||||
.watchCollection(collections.require(subscription.collectionProfile()))
|
||||
.resumeAt(optionsFor(subscription, resumeFrom))
|
||||
.listen();
|
||||
}
|
||||
|
||||
private static ChangeStreamOptions optionsFor(
|
||||
MongoChangeStreamSubscription subscription, Optional<ResumeFrom> resumeFrom) {
|
||||
ChangeStreamOptions.ChangeStreamOptionsBuilder options =
|
||||
ChangeStreamOptions.builder()
|
||||
.fullDocumentLookup(FullDocument.UPDATE_LOOKUP)
|
||||
.fullDocumentBeforeChangeLookup(
|
||||
subscription.fullDocumentBeforeChange()
|
||||
? FullDocumentBeforeChange.WHEN_AVAILABLE
|
||||
: FullDocumentBeforeChange.OFF);
|
||||
resumeFrom.ifPresent(
|
||||
from -> {
|
||||
if (from.position() == MongoResumePosition.START_AFTER) {
|
||||
options.startAfter(from.token());
|
||||
} else {
|
||||
options.resumeAfter(from.token());
|
||||
}
|
||||
});
|
||||
return options.build();
|
||||
}
|
||||
}
|
||||
+31
-1
@@ -59,7 +59,20 @@ public final class DefaultMongoFailureTranslator implements MongoFailureTranslat
|
||||
Objects.requireNonNull(elapsed, "elapsed");
|
||||
Objects.requireNonNull(failure, "failure");
|
||||
|
||||
MongoFailureClassification classification = classifier.classify(failure);
|
||||
// The operation type and the phase, not just the failure.
|
||||
//
|
||||
// This method already held the operation type and called the context-free overload, so the
|
||||
// distinction the phase-aware classifier exists to make — a lost response on a FIND is a
|
||||
// repeatable read, the same loss on an UPDATE is a write of unknown outcome — was discarded on
|
||||
// every non-transaction path. Both executors translate through here, so that was every ordinary
|
||||
// operation: a failed read was reported as an ambiguous write.
|
||||
//
|
||||
// The phase is derived from what the driver saw rather than passed in, because that is the only
|
||||
// evidence available outside a transaction: nothing sent is a server-selection failure, sent
|
||||
// without a response is the ambiguous window, and a received response means the command
|
||||
// completed and the failure is in its content.
|
||||
MongoFailureClassification classification =
|
||||
classifier.classify(operationType, phaseOf(failure), failure);
|
||||
MongoFailureContext failureContext =
|
||||
new MongoFailureContext(
|
||||
MongoOperationScope.of(context),
|
||||
@@ -103,4 +116,21 @@ public final class DefaultMongoFailureTranslator implements MongoFailureTranslat
|
||||
new MongoUnclassifiedFailureException(failureContext);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The phase a driver failure reports about itself.
|
||||
*
|
||||
* @param failure the narrowed driver view
|
||||
* @return the phase the evidence supports
|
||||
*/
|
||||
private static MongoFailurePhase phaseOf(MongoDriverFailureView failure) {
|
||||
if (!failure.commandWasSent()) {
|
||||
// Conservative on purpose: `commandWasSent` is already conservative, so a false here means
|
||||
// the driver is sure nothing left, which is the one case that is safely retryable.
|
||||
return MongoFailurePhase.SERVER_SELECTION;
|
||||
}
|
||||
return failure.responseWasReceived()
|
||||
? MongoFailurePhase.RESPONSE_WAIT
|
||||
: MongoFailurePhase.COMMAND_SEND;
|
||||
}
|
||||
}
|
||||
|
||||
+41
-7
@@ -1,5 +1,6 @@
|
||||
package dev.caskeleton.adapter.outbound.mongo.imperative;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -14,6 +15,15 @@ import org.springframework.data.mongodb.core.query.Update;
|
||||
* <p>Every method passes the collection this scope was built with. That is the whole implementation
|
||||
* and the whole point: a caller cannot supply a different one, so the registered collection profile
|
||||
* and any tenant boundary derived from it hold without the caller having to cooperate.
|
||||
*
|
||||
* <p>Every query-shaped method also carries the operation's deadline as {@code maxTimeMS}, and that
|
||||
* is the difference between a deadline and a report about one. The blocking executor could only
|
||||
* measure elapsed time after the callback returned — a Java callback cannot be interrupted mid
|
||||
* driver call — so an operation that ran past its budget was detected, never stopped. Sent to the
|
||||
* server, the same number ends the work.
|
||||
*
|
||||
* <p>It applies to the methods that take a {@link Query} or an {@link Aggregation}, because those
|
||||
* are the ones the server can cut short. {@code insert} carries no query to attach it to.
|
||||
*/
|
||||
final class BoundScopedOperations implements ScopedMongoOperations {
|
||||
|
||||
@@ -21,9 +31,26 @@ final class BoundScopedOperations implements ScopedMongoOperations {
|
||||
|
||||
private final MongoOperations operations;
|
||||
|
||||
BoundScopedOperations(String collection, MongoOperations operations) {
|
||||
private final Duration timeout;
|
||||
|
||||
BoundScopedOperations(String collection, MongoOperations operations, Duration timeout) {
|
||||
this.collection = Objects.requireNonNull(collection, "collection");
|
||||
this.operations = Objects.requireNonNull(operations, "operations");
|
||||
this.timeout = Objects.requireNonNull(timeout, "timeout");
|
||||
if (timeout.isNegative() || timeout.isZero()) {
|
||||
throw new IllegalArgumentException("the operation timeout must be positive and finite");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches the operation deadline to a query.
|
||||
*
|
||||
* <p>Mutates the caller's query rather than copying it, which is what Spring Data's own fluent
|
||||
* API does; a copy would silently drop any hint, collation or read preference the caller set.
|
||||
*/
|
||||
private Query bounded(Query query) {
|
||||
Objects.requireNonNull(query, "query");
|
||||
return query.maxTimeMsec(timeout.toMillis());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -33,17 +60,17 @@ final class BoundScopedOperations implements ScopedMongoOperations {
|
||||
|
||||
@Override
|
||||
public <T> Optional<T> findOne(Query query, Class<T> documentType) {
|
||||
return Optional.ofNullable(operations.findOne(query, documentType, collection));
|
||||
return Optional.ofNullable(operations.findOne(bounded(query), documentType, collection));
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> List<T> findMany(Query query, Class<T> documentType) {
|
||||
return operations.find(query, documentType, collection);
|
||||
return operations.find(bounded(query), documentType, collection);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long count(Query query, Class<?> documentType) {
|
||||
return operations.count(query, documentType, collection);
|
||||
return operations.count(bounded(query), documentType, collection);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -53,16 +80,23 @@ final class BoundScopedOperations implements ScopedMongoOperations {
|
||||
|
||||
@Override
|
||||
public long updateOne(Query query, Update update, Class<?> documentType) {
|
||||
return operations.updateFirst(query, update, documentType, collection).getModifiedCount();
|
||||
return operations
|
||||
.updateFirst(bounded(query), update, documentType, collection)
|
||||
.getModifiedCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long deleteOne(Query query, Class<?> documentType) {
|
||||
return operations.remove(query, documentType, collection).getDeletedCount();
|
||||
return operations.remove(bounded(query), documentType, collection).getDeletedCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> List<T> aggregate(Aggregation aggregation, Class<T> resultType) {
|
||||
return operations.aggregate(aggregation, collection, resultType).getMappedResults();
|
||||
Aggregation bounded =
|
||||
aggregation.withOptions(
|
||||
org.springframework.data.mongodb.core.aggregation.AggregationOptions.builder()
|
||||
.maxTime(timeout)
|
||||
.build());
|
||||
return operations.aggregate(bounded, collection, resultType).getMappedResults();
|
||||
}
|
||||
}
|
||||
|
||||
+10
-3
@@ -80,7 +80,7 @@ public final class DefaultMongoImperativeExecutor implements MongoImperativeExec
|
||||
|
||||
String physicalCollection = collections.require(context.collectionProfile());
|
||||
MongoOperations operations = consistency.templateFor(context.consistency());
|
||||
ScopedAccess access = new ScopedAccess(physicalCollection, operations);
|
||||
ScopedAccess access = new ScopedAccess(physicalCollection, operations, context.timeout());
|
||||
|
||||
long startedAt = System.nanoTime();
|
||||
try (MongoOperationObservation observation = observer.start(context, operationType)) {
|
||||
@@ -151,9 +151,13 @@ public final class DefaultMongoImperativeExecutor implements MongoImperativeExec
|
||||
|
||||
private final MongoOperations operations;
|
||||
|
||||
private ScopedAccess(String physicalCollection, MongoOperations operations) {
|
||||
private final java.time.Duration timeout;
|
||||
|
||||
private ScopedAccess(
|
||||
String physicalCollection, MongoOperations operations, java.time.Duration timeout) {
|
||||
this.physicalCollection = physicalCollection;
|
||||
this.operations = operations;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -178,7 +182,10 @@ public final class DefaultMongoImperativeExecutor implements MongoImperativeExec
|
||||
|
||||
@Override
|
||||
public ScopedMongoOperations scoped() {
|
||||
return new BoundScopedOperations(physicalCollection, operations);
|
||||
// The deadline travels with the scope. The executor can only measure elapsed time after the
|
||||
// callback returns; the scoped operations send the same number to the server as maxTimeMS,
|
||||
// where it actually ends the work.
|
||||
return new BoundScopedOperations(physicalCollection, operations, timeout);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+22
@@ -44,6 +44,28 @@ public record MongoTemplateSupportContract(
|
||||
null, WriteResultChecking.NONE, true, Objects.requireNonNull(context, "context"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies this contract to a derived reactive template.
|
||||
*
|
||||
* <p>The reactive binder derived its templates with read preference and write concern only, so a
|
||||
* document written through the reactive executor skipped auditing and {@code
|
||||
* BeforeConvertCallback} while the same document written through an ordinary reactive repository
|
||||
* did not. The imperative half was fixed and the reactive half was left, which stopped being
|
||||
* theoretical when the reactive execution path was wired.
|
||||
*
|
||||
* @param template the derived reactive template
|
||||
*/
|
||||
public void applyTo(org.springframework.data.mongodb.core.ReactiveMongoTemplate template) {
|
||||
Objects.requireNonNull(template, "template");
|
||||
if (writeConcernResolver != null) {
|
||||
template.setWriteConcernResolver(writeConcernResolver);
|
||||
}
|
||||
template.setWriteResultChecking(
|
||||
writeResultChecking == null ? WriteResultChecking.NONE : writeResultChecking);
|
||||
template.setEntityLifecycleEventsEnabled(entityLifecycleEventsEnabled);
|
||||
context().ifPresent(template::setApplicationContext);
|
||||
}
|
||||
|
||||
/** Applies this contract to a derived template. */
|
||||
public void applyTo(MongoTemplate template) {
|
||||
Objects.requireNonNull(template, "template");
|
||||
|
||||
+4
-18
@@ -1,11 +1,8 @@
|
||||
package dev.caskeleton.adapter.outbound.mongo.imperative.atomic;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName;
|
||||
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext;
|
||||
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType;
|
||||
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
|
||||
import dev.caskeleton.adapter.outbound.mongo.imperative.DefaultMongoImperativeExecutor;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.springframework.data.mongodb.core.FindAndModifyOptions;
|
||||
import org.springframework.data.mongodb.core.MongoOperations;
|
||||
@@ -25,13 +22,12 @@ public final class MongoAtomicOperationsTemplate implements MongoAtomicOperation
|
||||
|
||||
private final DefaultMongoImperativeExecutor executor;
|
||||
|
||||
private final Map<CollectionProfileName, MongoAtomicPolicy> policies;
|
||||
private final MongoAtomicPolicyRegistry policies;
|
||||
|
||||
public MongoAtomicOperationsTemplate(
|
||||
DefaultMongoImperativeExecutor executor,
|
||||
Map<CollectionProfileName, MongoAtomicPolicy> policies) {
|
||||
DefaultMongoImperativeExecutor executor, MongoAtomicPolicyRegistry policies) {
|
||||
this.executor = Objects.requireNonNull(executor, "executor");
|
||||
this.policies = Map.copyOf(Objects.requireNonNull(policies, "policies"));
|
||||
this.policies = Objects.requireNonNull(policies, "policies");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -67,7 +63,7 @@ public final class MongoAtomicOperationsTemplate implements MongoAtomicOperation
|
||||
Objects.requireNonNull(update, "update");
|
||||
Objects.requireNonNull(returnMode, "returnMode");
|
||||
|
||||
MongoAtomicPolicy policy = requirePolicy(context.collectionProfile());
|
||||
MongoAtomicPolicy policy = policies.require(context.collectionProfile());
|
||||
policy.requireFilter(filter);
|
||||
policy.requireUpdate(update);
|
||||
|
||||
@@ -141,14 +137,4 @@ public final class MongoAtomicOperationsTemplate implements MongoAtomicOperation
|
||||
}
|
||||
return AtomicUpdateResult.applied(result.getMatchedCount(), result.getModifiedCount(), null);
|
||||
}
|
||||
|
||||
private MongoAtomicPolicy requirePolicy(CollectionProfileName profile) {
|
||||
MongoAtomicPolicy policy = policies.get(profile);
|
||||
if (policy == null) {
|
||||
throw MongoOperationRejectedException.of(
|
||||
"atomic.policy",
|
||||
"collection profile '" + profile + "' has no registered atomic update policy");
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
}
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package dev.caskeleton.adapter.outbound.mongo.imperative.atomic;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName;
|
||||
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Which atomic update policy governs each collection profile.
|
||||
*
|
||||
* <p>The policies used to be a bare {@code Map} passed to whichever object happened to be
|
||||
* constructed with them, which is why the single-document path and the batch path could disagree
|
||||
* about them: the atomic template refused an operation on a profile with no policy, and the bulk
|
||||
* executor was constructible with an empty map and silently validated nothing. Putting the map
|
||||
* behind one type with one lookup is what makes "the same policy governs both paths" a property of
|
||||
* the code rather than of the wiring.
|
||||
*
|
||||
* <p>A Spring context cannot inject a {@code Map} keyed by anything other than bean names, so this
|
||||
* is also the shape that lets a composition root contribute policies as a single bean.
|
||||
*/
|
||||
public final class MongoAtomicPolicyRegistry {
|
||||
|
||||
private final Map<CollectionProfileName, MongoAtomicPolicy> policies;
|
||||
|
||||
private MongoAtomicPolicyRegistry(Map<CollectionProfileName, MongoAtomicPolicy> policies) {
|
||||
this.policies = policies;
|
||||
}
|
||||
|
||||
/** Starts a registry declaration. */
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
/**
|
||||
* A registry a deployment has declared nothing in.
|
||||
*
|
||||
* <p>Empty means every atomic and bulk operation is refused, which is the safe reading: a
|
||||
* collection whose modifiable fields nobody declared has no field a partial update may touch.
|
||||
*
|
||||
* @return a registry with no registered profile
|
||||
*/
|
||||
public static MongoAtomicPolicyRegistry empty() {
|
||||
return new MongoAtomicPolicyRegistry(Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the policy governing a profile.
|
||||
*
|
||||
* @param profile the collection profile
|
||||
* @return the registered policy
|
||||
* @throws MongoOperationRejectedException when the profile has no registered policy
|
||||
*/
|
||||
public MongoAtomicPolicy require(CollectionProfileName profile) {
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
MongoAtomicPolicy policy = policies.get(profile);
|
||||
if (policy == null) {
|
||||
throw MongoOperationRejectedException.of(
|
||||
"atomic.policy",
|
||||
"collection profile '" + profile + "' has no registered atomic update policy");
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether a profile has a registered policy.
|
||||
*
|
||||
* @param profile the collection profile
|
||||
* @return true when a policy is registered
|
||||
*/
|
||||
public boolean isRegistered(CollectionProfileName profile) {
|
||||
return profile != null && policies.containsKey(profile);
|
||||
}
|
||||
|
||||
/** Collects the policies of one deployment. */
|
||||
public static final class Builder {
|
||||
|
||||
private final Map<CollectionProfileName, MongoAtomicPolicy> policies = new LinkedHashMap<>();
|
||||
|
||||
private Builder() {}
|
||||
|
||||
/**
|
||||
* Registers the policy governing one collection profile.
|
||||
*
|
||||
* @param profile the collection profile
|
||||
* @param policy the policy governing it
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder register(CollectionProfileName profile, MongoAtomicPolicy policy) {
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
Objects.requireNonNull(policy, "policy");
|
||||
if (policies.putIfAbsent(profile, policy) != null) {
|
||||
// Two declarations for one profile means one of them is not in force and the reader cannot
|
||||
// tell which. A refused startup is cheaper than a field that is protected in the document
|
||||
// one developer read and writable through the policy the context happened to keep.
|
||||
throw new IllegalArgumentException(
|
||||
"collection profile '" + profile + "' already has a registered atomic update policy");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Builds the immutable registry. */
|
||||
public MongoAtomicPolicyRegistry build() {
|
||||
return new MongoAtomicPolicyRegistry(Map.copyOf(policies));
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-24
@@ -6,6 +6,8 @@ import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext;
|
||||
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType;
|
||||
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureCategory;
|
||||
import dev.caskeleton.adapter.outbound.mongo.imperative.DefaultMongoImperativeExecutor;
|
||||
import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy;
|
||||
import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicyRegistry;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@@ -26,31 +28,22 @@ public final class MongoBulkExecutor {
|
||||
|
||||
private final DefaultMongoImperativeExecutor executor;
|
||||
|
||||
private final java.util.Map<
|
||||
dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName,
|
||||
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy>
|
||||
policies;
|
||||
|
||||
public MongoBulkExecutor(DefaultMongoImperativeExecutor executor) {
|
||||
this(executor, java.util.Map.of());
|
||||
}
|
||||
private final MongoAtomicPolicyRegistry policies;
|
||||
|
||||
/**
|
||||
* A bulk executor that enforces the same field and operator policy the atomic path enforces.
|
||||
*
|
||||
* <p>The single-document path validated every filter and update against the collection's policy;
|
||||
* the bulk path did not look at it. Protected fields and unregistered operators were therefore
|
||||
* reachable by putting the same update inside a batch — the policy held for one document and not
|
||||
* for a thousand.
|
||||
* the bulk path did not look at it, and its policies were an optional constructor argument that
|
||||
* defaulted to none. Protected fields and unregistered operators were therefore reachable by
|
||||
* putting the same update inside a batch — the policy held for one document and not for a
|
||||
* thousand. There is one constructor now because an executor that can be built without policies
|
||||
* is how that bypass survives a review of the validation code itself.
|
||||
*/
|
||||
public MongoBulkExecutor(
|
||||
DefaultMongoImperativeExecutor executor,
|
||||
java.util.Map<
|
||||
dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName,
|
||||
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy>
|
||||
policies) {
|
||||
DefaultMongoImperativeExecutor executor, MongoAtomicPolicyRegistry policies) {
|
||||
this.executor = Objects.requireNonNull(executor, "executor");
|
||||
this.policies = java.util.Map.copyOf(Objects.requireNonNull(policies, "policies"));
|
||||
this.policies = Objects.requireNonNull(policies, "policies");
|
||||
}
|
||||
|
||||
/** Runs one bulk plan against the operation's collection. */
|
||||
@@ -58,13 +51,13 @@ public final class MongoBulkExecutor {
|
||||
Objects.requireNonNull(context, "context");
|
||||
Objects.requireNonNull(plan, "plan");
|
||||
|
||||
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy policy =
|
||||
policies.get(context.collectionProfile());
|
||||
if (policy != null) {
|
||||
for (MongoBulkWritePlan.MongoBulkItem item : plan.items()) {
|
||||
policy.requireFilter(item.filter());
|
||||
policy.requireUpdate(item.update());
|
||||
}
|
||||
// Refused here, before anything is sent, and refused for a profile with no policy at all —
|
||||
// the same reading the single-document path applies. A batch is validated as a whole because
|
||||
// an ordered batch that fails halfway leaves the earlier items applied.
|
||||
MongoAtomicPolicy policy = policies.require(context.collectionProfile());
|
||||
for (MongoBulkWritePlan.MongoBulkItem item : plan.items()) {
|
||||
policy.requireFilter(item.filter());
|
||||
policy.requireUpdate(item.update());
|
||||
}
|
||||
|
||||
return executor
|
||||
|
||||
+35
-3
@@ -45,16 +45,48 @@ public class MongoMappingConfiguration {
|
||||
return MongoCustomConversionsFactory.forManifest(manifest);
|
||||
}
|
||||
|
||||
/** Refuses implicit, JVM-zone-dependent {@code LocalDateTime} storage. */
|
||||
/**
|
||||
* Refuses implicit, JVM-zone-dependent {@code LocalDateTime} storage.
|
||||
*
|
||||
* <p>Built from the converters this deployment actually registered, not from an empty set. The
|
||||
* guard used to be constructed {@code withoutConverters()} and then asked to validate the
|
||||
* manifest, so it compared a declaration against nothing: it could only ever reject {@code
|
||||
* LOCAL_DATE_TIME_WITH_REGISTERED_CONVERTER}, and a deployment that *did* register the named
|
||||
* converter was rejected exactly as loudly as one that did not. The check that exists to
|
||||
* distinguish those two cases could not tell them apart.
|
||||
*
|
||||
* @param manifest the frozen representation manifest
|
||||
* @param conversions the converters Boot assembled for this deployment
|
||||
* @return the guard
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public LocalDateTimeMappingGuard localDateTimeMappingGuard(
|
||||
MongoTypeRepresentationManifest manifest) {
|
||||
LocalDateTimeMappingGuard guard = LocalDateTimeMappingGuard.withoutConverters();
|
||||
MongoTypeRepresentationManifest manifest,
|
||||
org.springframework.data.mongodb.core.convert.MongoCustomConversions conversions) {
|
||||
LocalDateTimeMappingGuard guard =
|
||||
new LocalDateTimeMappingGuard(registeredTemporalConverterNames(conversions));
|
||||
guard.validate(manifest);
|
||||
return guard;
|
||||
}
|
||||
|
||||
/**
|
||||
* The temporal converter names this deployment registered.
|
||||
*
|
||||
* <p>Spring's {@code MongoCustomConversions} does not expose its converter list, so presence is
|
||||
* asked the way the mapping layer itself asks it: can a {@code LocalDateTime} be written as a
|
||||
* BSON date. A deployment that registered the named converter answers yes; one that did not
|
||||
* answers no, and that is the whole distinction the guard needs.
|
||||
*/
|
||||
private static java.util.Set<String> registeredTemporalConverterNames(
|
||||
org.springframework.data.mongodb.core.convert.MongoCustomConversions conversions) {
|
||||
boolean localDateTimeIsConverted =
|
||||
conversions.hasCustomWriteTarget(java.time.LocalDateTime.class);
|
||||
return localDateTimeIsConverted
|
||||
? java.util.Set.of(LocalDateTimeMappingGuard.REQUIRED_CONVERTER)
|
||||
: java.util.Set.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the per-collection type metadata policy to the live converter.
|
||||
*
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user