diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1bf856878..173911a9a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -177,6 +177,12 @@ [libraries.spring-boot-starter-test.version] ref = 'spring-boot' + [libraries.spring-boot-configuration-processor] + module = 'org.springframework.boot:spring-boot-configuration-processor' + + [libraries.spring-boot-configuration-processor.version] + ref = 'spring-boot' + [libraries.vertx-core] module = 'io.vertx:vertx-core' @@ -237,6 +243,6 @@ protobuf = '4.29.3' restate = '2.6.0-SNAPSHOT' schema-kenerator = '2.1.2' - spring-boot = '3.4.9' + spring-boot = '3.5.10' vertx = '4.5.22' victools-json-schema = '4.38.0' diff --git a/sdk-spring-boot-kotlin-starter/src/test/kotlin/dev/restate/sdk/springboot/kotlin/RestateHttpEndpointBeanTest.kt b/sdk-spring-boot-kotlin-starter/src/test/kotlin/dev/restate/sdk/springboot/kotlin/RestateHttpEndpointBeanTest.kt index 165dc3148..08ba54cbe 100644 --- a/sdk-spring-boot-kotlin-starter/src/test/kotlin/dev/restate/sdk/springboot/kotlin/RestateHttpEndpointBeanTest.kt +++ b/sdk-spring-boot-kotlin-starter/src/test/kotlin/dev/restate/sdk/springboot/kotlin/RestateHttpEndpointBeanTest.kt @@ -10,6 +10,8 @@ package dev.restate.sdk.springboot.kotlin import com.fasterxml.jackson.databind.ObjectMapper import dev.restate.sdk.core.generated.manifest.EndpointManifestSchema +import dev.restate.sdk.springboot.RestateEndpointConfiguration +import dev.restate.sdk.springboot.RestateHttpConfiguration import dev.restate.sdk.springboot.RestateHttpEndpointBean import java.io.IOException import java.net.URI @@ -22,7 +24,8 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest @SpringBootTest( - classes = [RestateHttpEndpointBean::class, Greeter::class], + classes = + [RestateEndpointConfiguration::class, RestateHttpConfiguration::class, Greeter::class], properties = ["restate.sdk.http.port=0"], ) class RestateHttpEndpointBeanTest { diff --git a/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/java/RestateHttpEndpointBeanTest.java b/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/java/RestateHttpEndpointBeanTest.java index 5bce0cc9b..dcec198a9 100644 --- a/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/java/RestateHttpEndpointBeanTest.java +++ b/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/java/RestateHttpEndpointBeanTest.java @@ -12,19 +12,31 @@ import com.fasterxml.jackson.databind.ObjectMapper; import dev.restate.sdk.core.generated.manifest.EndpointManifestSchema; +import dev.restate.sdk.springboot.RestateEndpointConfiguration; +import dev.restate.sdk.springboot.RestateHttpConfiguration; import dev.restate.sdk.springboot.RestateHttpEndpointBean; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.Duration; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest( - classes = {RestateHttpEndpointBean.class, Greeter.class, Configuration.class}, - properties = {"restate.sdk.http.port=0"}) + classes = { + RestateEndpointConfiguration.class, + RestateHttpConfiguration.class, + Greeter.class, + ServicesConfiguration.class + }, + properties = { + "restate.sdk.http.port=0", + "restate.components.greeter.journal-retention=PT48H", + "greetingPrefix=Hello " + }) public class RestateHttpEndpointBeanTest { @Autowired private RestateHttpEndpointBean restateHttpEndpointBean; @@ -56,7 +68,8 @@ public void httpEndpointShouldBeRunning() throws IOException, InterruptedExcepti assertThat(endpointManifest.getServices()) .extracting( dev.restate.sdk.core.generated.manifest.Service::getName, - dev.restate.sdk.core.generated.manifest.Service::getDocumentation) - .containsOnly(tuple("greeter", "blabla")); + dev.restate.sdk.core.generated.manifest.Service::getDocumentation, + dev.restate.sdk.core.generated.manifest.Service::getJournalRetention) + .containsOnly(tuple("greeter", "blabla", Duration.ofDays(2).toMillis())); } } diff --git a/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/java/Configuration.java b/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/java/ServicesConfiguration.java similarity index 55% rename from sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/java/Configuration.java rename to sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/java/ServicesConfiguration.java index e03618d09..2b6de0d8a 100644 --- a/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/java/Configuration.java +++ b/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/java/ServicesConfiguration.java @@ -8,12 +8,17 @@ // https://github.com/restatedev/sdk-java/blob/main/LICENSE package dev.restate.sdk.springboot.java; -import dev.restate.sdk.springboot.RestateServiceConfigurator; +import dev.restate.sdk.springboot.RestateComponentProperties; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ServicesConfiguration { -public class Configuration { @Bean - public RestateServiceConfigurator greeterConfiguration() { - return configurator -> configurator.documentation("blabla"); + public RestateComponentProperties greeterConfiguration() { + var properties = new RestateComponentProperties(); + properties.setDocumentation("blabla"); + return properties; } } diff --git a/sdk-spring-boot/build.gradle.kts b/sdk-spring-boot/build.gradle.kts index 30700cd1e..9b0aa6f91 100644 --- a/sdk-spring-boot/build.gradle.kts +++ b/sdk-spring-boot/build.gradle.kts @@ -10,6 +10,9 @@ description = "Restate SDK Spring Boot integration" dependencies { compileOnly(libs.jspecify) + // This generates the metadata needed for the configuration properties + annotationProcessor(libs.spring.boot.configuration.processor) + val excludeJackson = fun ProjectDependency.() { // Let spring bring jackson in diff --git a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/EnableRestate.java b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/EnableRestate.java index b9534f759..086430445 100644 --- a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/EnableRestate.java +++ b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/EnableRestate.java @@ -22,5 +22,9 @@ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented -@Import({RestateHttpEndpointBean.class, RestateClientAutoConfiguration.class}) +@Import({ + RestateEndpointConfiguration.class, + RestateHttpConfiguration.class, + RestateClientAutoConfiguration.class +}) public @interface EnableRestate {} diff --git a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateComponent.java b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateComponent.java index f8af3d2e0..c83670f06 100644 --- a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateComponent.java +++ b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateComponent.java @@ -25,8 +25,9 @@ @Component public @interface RestateComponent { /** - * Bean name to use to configure this component. The bean MUST be an instance of {@link - * RestateServiceConfigurator}. + * Bean name to use to configure this component. + * + *

The bean MUST be an instance of {@link RestateComponentProperties}. */ String configuration() default ""; } diff --git a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateComponentProperties.java b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateComponentProperties.java new file mode 100644 index 000000000..0dcd42a5b --- /dev/null +++ b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateComponentProperties.java @@ -0,0 +1,398 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.springboot; + +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Configuration properties for a Restate service/component. + * + *

These properties can be used to configure Restate services via Spring configuration files. + */ +public class RestateComponentProperties { + + @Nullable private String executor; + @Nullable private String documentation; + @Nullable private Map metadata; + @Nullable private Duration inactivityTimeout; + @Nullable private Duration abortTimeout; + @Nullable private Duration idempotencyRetention; + @Nullable private Duration workflowRetention; + @Nullable private Duration journalRetention; + @Nullable private Boolean ingressPrivate; + @Nullable private Boolean enableLazyState; + @Nullable private RetryPolicyProperties retryPolicy; + @Nullable private Map handlers; + + public RestateComponentProperties() {} + + public RestateComponentProperties( + @Nullable String executor, + @Nullable String documentation, + @Nullable Map metadata, + @Nullable Duration inactivityTimeout, + @Nullable Duration abortTimeout, + @Nullable Duration idempotencyRetention, + @Nullable Duration workflowRetention, + @Nullable Duration journalRetention, + @Nullable Boolean ingressPrivate, + @Nullable Boolean enableLazyState, + @Nullable RetryPolicyProperties retryPolicy, + @Nullable Map handlers) { + this.executor = executor; + this.documentation = documentation; + this.metadata = metadata; + this.inactivityTimeout = inactivityTimeout; + this.abortTimeout = abortTimeout; + this.idempotencyRetention = idempotencyRetention; + this.workflowRetention = workflowRetention; + this.journalRetention = journalRetention; + this.ingressPrivate = ingressPrivate; + this.enableLazyState = enableLazyState; + this.retryPolicy = retryPolicy; + this.handlers = handlers; + } + + /** + * Name of the {@link java.util.concurrent.Executor} bean to use for running handlers of this + * service. If not specified, the global default from {@link RestateComponentsProperties} is used. + * + *

NOTE: This option is only used for Java services, not Kotlin services. + * + *

If not specified (neither here nor globally), virtual threads are used for Java >= 21, + * otherwise {@link java.util.concurrent.Executors#newCachedThreadPool()} is used. See {@code + * HandlerRunner.Options.withExecutor()} for more details. + */ + public @Nullable String getExecutor() { + return executor; + } + + /** + * Name of the {@link java.util.concurrent.Executor} bean to use for running handlers of this + * service. If not specified, the global default from {@link RestateComponentsProperties} is used. + * + *

NOTE: This option is only used for Java services, not Kotlin services. + * + *

If not specified (neither here nor globally), virtual threads are used for Java >= 21, + * otherwise {@link java.util.concurrent.Executors#newCachedThreadPool()} is used. See {@code + * HandlerRunner.Options.withExecutor()} for more details. + */ + public void setExecutor(@Nullable String executor) { + this.executor = executor; + } + + /** + * Documentation as shown in the UI, Admin REST API, and the generated OpenAPI documentation of + * this service. + */ + public @Nullable String getDocumentation() { + return documentation; + } + + /** + * Documentation as shown in the UI, Admin REST API, and the generated OpenAPI documentation of + * this service. + */ + public void setDocumentation(@Nullable String documentation) { + this.documentation = documentation; + } + + /** Service metadata, as propagated in the Admin REST API. */ + public @Nullable Map getMetadata() { + return metadata; + } + + /** Service metadata, as propagated in the Admin REST API. */ + public void setMetadata(@Nullable Map metadata) { + this.metadata = metadata; + } + + /** + * This timer guards against stalled invocations. Once it expires, Restate triggers a graceful + * termination by asking the invocation to suspend (which preserves intermediate progress). + * + *

The {@link #getAbortTimeout()} is used to abort the invocation, in case it doesn't react to + * the request to suspend. + * + *

This overrides the default inactivity timeout configured in the restate-server for all + * invocations to this service. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public @Nullable Duration getInactivityTimeout() { + return inactivityTimeout; + } + + /** + * This timer guards against stalled invocations. Once it expires, Restate triggers a graceful + * termination by asking the invocation to suspend (which preserves intermediate progress). + * + *

The {@link #getAbortTimeout()} is used to abort the invocation, in case it doesn't react to + * the request to suspend. + * + *

This overrides the default inactivity timeout configured in the restate-server for all + * invocations to this service. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public void setInactivityTimeout(@Nullable Duration inactivityTimeout) { + this.inactivityTimeout = inactivityTimeout; + } + + /** + * This timer guards against stalled service/handler invocations that are supposed to terminate. + * The abort timeout is started after the {@link #getInactivityTimeout()} has expired and the + * service/handler invocation has been asked to gracefully terminate. Once the timer expires, it + * will abort the service/handler invocation. + * + *

This timer potentially interrupts user code. If the user code needs longer to + * gracefully terminate, then this value needs to be set accordingly. + * + *

This overrides the default abort timeout configured in the restate-server for all + * invocations to this service. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public @Nullable Duration getAbortTimeout() { + return abortTimeout; + } + + /** + * This timer guards against stalled service/handler invocations that are supposed to terminate. + * The abort timeout is started after the {@link #getInactivityTimeout()} has expired and the + * service/handler invocation has been asked to gracefully terminate. Once the timer expires, it + * will abort the service/handler invocation. + * + *

This timer potentially interrupts user code. If the user code needs longer to + * gracefully terminate, then this value needs to be set accordingly. + * + *

This overrides the default abort timeout configured in the restate-server for all + * invocations to this service. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public void setAbortTimeout(@Nullable Duration abortTimeout) { + this.abortTimeout = abortTimeout; + } + + /** + * The retention duration of idempotent requests to this service. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public @Nullable Duration getIdempotencyRetention() { + return idempotencyRetention; + } + + /** + * The retention duration of idempotent requests to this service. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public void setIdempotencyRetention(@Nullable Duration idempotencyRetention) { + this.idempotencyRetention = idempotencyRetention; + } + + /** + * The retention duration of idempotent requests to this workflow service. This applies only to + * workflow services. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public @Nullable Duration getWorkflowRetention() { + return workflowRetention; + } + + /** + * The retention duration of idempotent requests to this workflow service. This applies only to + * workflow services. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public void setWorkflowRetention(@Nullable Duration workflowRetention) { + this.workflowRetention = workflowRetention; + } + + /** + * The journal retention. When set, this applies to all requests to all handlers of this service. + * + *

In case the request has an idempotency key, the {@link #getIdempotencyRetention()} caps the + * journal retention time. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public @Nullable Duration getJournalRetention() { + return journalRetention; + } + + /** + * The journal retention. When set, this applies to all requests to all handlers of this service. + * + *

In case the request has an idempotency key, the {@link #getIdempotencyRetention()} caps the + * journal retention time. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public void setJournalRetention(@Nullable Duration journalRetention) { + this.journalRetention = journalRetention; + } + + /** + * When set to {@code true} this service, with all its handlers, cannot be invoked from the + * restate-server HTTP and Kafka ingress, but only from other services. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public @Nullable Boolean getIngressPrivate() { + return ingressPrivate; + } + + /** + * When set to {@code true} this service, with all its handlers, cannot be invoked from the + * restate-server HTTP and Kafka ingress, but only from other services. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public void setIngressPrivate(@Nullable Boolean ingressPrivate) { + this.ingressPrivate = ingressPrivate; + } + + /** + * When set to {@code true}, lazy state will be enabled for all invocations to this service. This + * is relevant only for workflows and virtual objects. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public @Nullable Boolean getEnableLazyState() { + return enableLazyState; + } + + /** + * When set to {@code true}, lazy state will be enabled for all invocations to this service. This + * is relevant only for workflows and virtual objects. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public void setEnableLazyState(@Nullable Boolean enableLazyState) { + this.enableLazyState = enableLazyState; + } + + /** + * Retry policy used by Restate when invoking this service. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.5, otherwise the service discovery will fail. + */ + public @Nullable RetryPolicyProperties getRetryPolicy() { + return retryPolicy; + } + + /** + * Retry policy used by Restate when invoking this service. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.5, otherwise the service discovery will fail. + */ + public void setRetryPolicy(@Nullable RetryPolicyProperties retryPolicy) { + this.retryPolicy = retryPolicy; + } + + /** Per-handler configuration, keyed by handler name. */ + public @Nullable Map getHandlers() { + return handlers; + } + + /** Per-handler configuration, keyed by handler name. */ + public void setHandlers(@Nullable Map handlers) { + this.handlers = handlers; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof RestateComponentProperties that)) return false; + return Objects.equals(getExecutor(), that.getExecutor()) + && Objects.equals(getDocumentation(), that.getDocumentation()) + && Objects.equals(getMetadata(), that.getMetadata()) + && Objects.equals(getInactivityTimeout(), that.getInactivityTimeout()) + && Objects.equals(getAbortTimeout(), that.getAbortTimeout()) + && Objects.equals(getIdempotencyRetention(), that.getIdempotencyRetention()) + && Objects.equals(getWorkflowRetention(), that.getWorkflowRetention()) + && Objects.equals(getJournalRetention(), that.getJournalRetention()) + && Objects.equals(getIngressPrivate(), that.getIngressPrivate()) + && Objects.equals(getEnableLazyState(), that.getEnableLazyState()) + && Objects.equals(getRetryPolicy(), that.getRetryPolicy()) + && Objects.equals(getHandlers(), that.getHandlers()); + } + + @Override + public int hashCode() { + return Objects.hash( + getExecutor(), + getDocumentation(), + getMetadata(), + getInactivityTimeout(), + getAbortTimeout(), + getIdempotencyRetention(), + getWorkflowRetention(), + getJournalRetention(), + getIngressPrivate(), + getEnableLazyState(), + getRetryPolicy(), + getHandlers()); + } + + @Override + public String toString() { + return "RestateComponentProperties{" + + "executor='" + + executor + + '\'' + + ", documentation='" + + documentation + + '\'' + + ", metadata=" + + metadata + + ", inactivityTimeout=" + + inactivityTimeout + + ", abortTimeout=" + + abortTimeout + + ", idempotencyRetention=" + + idempotencyRetention + + ", workflowRetention=" + + workflowRetention + + ", journalRetention=" + + journalRetention + + ", ingressPrivate=" + + ingressPrivate + + ", enableLazyState=" + + enableLazyState + + ", retryPolicy=" + + retryPolicy + + ", handlers=" + + handlers + + '}'; + } +} diff --git a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateComponentsProperties.java b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateComponentsProperties.java new file mode 100644 index 000000000..dc82612a0 --- /dev/null +++ b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateComponentsProperties.java @@ -0,0 +1,111 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.springboot; + +import java.util.HashMap; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Properties for configuring Restate services. + * + *

Example configuration in {@code application.properties}: + * + *

{@code
+ * # Configuration for a service named "MyService"
+ * restate.components.MyService.executor=myServiceExecutor
+ * restate.components.MyService.inactivity-timeout=10m
+ * restate.components.MyService.abort-timeout=1m
+ * restate.components.MyService.idempotency-retention=1d
+ * restate.components.MyService.journal-retention=7d
+ * restate.components.MyService.ingress-private=false
+ * restate.components.MyService.enable-lazy-state=true
+ * restate.components.MyService.documentation=My service description
+ * restate.components.MyService.metadata.version=1.0
+ * restate.components.MyService.metadata.team=platform
+ * restate.components.MyService.retry-policy.initial-interval=100ms
+ * restate.components.MyService.retry-policy.exponentiation-factor=2.0
+ * restate.components.MyService.retry-policy.max-interval=10s
+ * restate.components.MyService.retry-policy.max-attempts=10
+ * restate.components.MyService.retry-policy.on-max-attempts=PAUSE
+ *
+ * # Per-handler configuration
+ * restate.components.MyService.handlers.myHandler.inactivity-timeout=5m
+ * restate.components.MyService.handlers.myHandler.ingress-private=true
+ * restate.components.MyService.handlers.myHandler.documentation=Handler description
+ * restate.components.MyService.handlers.myWorkflowHandler.workflow-retention=30d
+ * }
+ */ +@ConfigurationProperties(prefix = "restate") +public class RestateComponentsProperties { + + @Nullable private String executor; + + // Map keyed by function bean name (e.g. restate.function.my-function.inactivity-timeout) + private Map components = new HashMap<>(); + + /** + * Name of the {@link java.util.concurrent.Executor} bean to use for running handlers of all + * services. This is the global default and can be overridden per-service in {@link + * #getComponents()}. + * + *

NOTE: This option is only used for Java services, not Kotlin services. + * + *

If not specified, virtual threads are used for Java >= 21, otherwise {@link + * java.util.concurrent.Executors#newCachedThreadPool()} is used. See {@code + * HandlerRunner.Options.withExecutor()} for more details. + */ + public @Nullable String getExecutor() { + return executor; + } + + /** + * Name of the {@link java.util.concurrent.Executor} bean to use for running handlers of all + * services. This is the global default and can be overridden per-service in {@link + * #getComponents()}. + * + *

NOTE: This option is only used for Java services, not Kotlin services. + * + *

If not specified, virtual threads are used for Java >= 21, otherwise {@link + * java.util.concurrent.Executors#newCachedThreadPool()} is used. See {@code + * HandlerRunner.Options.withExecutor()} for more details. + */ + public void setExecutor(@Nullable String executor) { + this.executor = executor; + } + + /** + * Per-component configuration, keyed by component/service name. + * + *

Example configuration in {@code application.properties}: + * + *

+   * restate.components.MyService.inactivity-timeout=10m
+   * restate.components.MyService.handlers.myHandler.ingress-private=true
+   * 
+ */ + public Map getComponents() { + return components; + } + + /** + * Per-component configuration, keyed by component/service name. + * + *

Example configuration in {@code application.properties}: + * + *

+   * restate.components.MyService.inactivity-timeout=10m
+   * restate.components.MyService.handlers.myHandler.ingress-private=true
+   * 
+ */ + public void setComponents(Map components) { + this.components = components; + } +} diff --git a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateEndpointConfiguration.java b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateEndpointConfiguration.java new file mode 100644 index 000000000..aabc94f4d --- /dev/null +++ b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateEndpointConfiguration.java @@ -0,0 +1,280 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.springboot; + +import dev.restate.common.reflections.ReflectionUtils; +import dev.restate.sdk.auth.signing.RestateRequestIdentityVerifier; +import dev.restate.sdk.endpoint.Endpoint; +import dev.restate.sdk.endpoint.definition.HandlerDefinition; +import dev.restate.sdk.endpoint.definition.HandlerRunner; +import dev.restate.sdk.endpoint.definition.InvocationRetryPolicy; +import dev.restate.sdk.endpoint.definition.ServiceDefinition; +import java.lang.reflect.Method; +import java.util.*; +import java.util.concurrent.Executor; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.AnnotatedElementUtils; + +@Configuration +@EnableConfigurationProperties({RestateEndpointProperties.class, RestateComponentsProperties.class}) +public class RestateEndpointConfiguration { + + // Cached reflection method for HandlerRunner.Options.withExecutor(Executor) + private static final Method WITH_EXECUTOR_METHOD; + + static { + Method method; + try { + Class optionsClass = Class.forName("dev.restate.sdk.HandlerRunner$Options"); + method = optionsClass.getMethod("withExecutor", Executor.class); + } catch (ClassNotFoundException | NoSuchMethodException e) { + // Leave it null, it will fail if being used + method = null; + } + WITH_EXECUTOR_METHOD = method; + } + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @SuppressWarnings("deprecation") + @Bean + public @Nullable Endpoint restateEndpoint( + ApplicationContext applicationContext, + RestateEndpointProperties restateEndpointProperties, + RestateComponentsProperties restateComponentsProperties) { + // Adapt beans to ServiceDefinition + var beansToAdapt = applicationContext.getBeansWithAnnotation(RestateComponent.class); + Map, Object>> discoveredRestateComponentBeans = + new HashMap<>(beansToAdapt.size()); + if (!beansToAdapt.isEmpty()) { + for (var bean : beansToAdapt.values()) { + var restateServiceDefinitionClazz = + ReflectionUtils.findRestateAnnotatedClass(bean.getClass()); + String restateName = ReflectionUtils.extractServiceName(restateServiceDefinitionClazz); + discoveredRestateComponentBeans.put( + restateName, Map.entry(restateServiceDefinitionClazz, bean)); + } + } + + if (discoveredRestateComponentBeans.isEmpty()) { + logger.info("No restate function/service/virtual object/workflow was found."); + // Don't start anything if no service is registered + return null; + } else { + logger.info( + "Registering Restate components: {}", + discoveredRestateComponentBeans.keySet().stream() + .collect(Collectors.joining(", ", "[", "]"))); + } + + // Create default handler runner options, if available + HandlerRunner.Options javaRunnerOptions = + createHandlerRunnerOptions(applicationContext, restateComponentsProperties.getExecutor()); + + // Build the Endpoint object + var builder = Endpoint.builder(); + for (var serviceNameAndBean : discoveredRestateComponentBeans.entrySet()) { + var serviceName = serviceNameAndBean.getKey(); + var restateServiceDefinitionClazz = serviceNameAndBean.getValue().getKey(); + var serviceInstance = serviceNameAndBean.getValue().getValue(); + var isKotlinClass = ReflectionUtils.isKotlinClass(restateServiceDefinitionClazz); + + var handlerOptions = javaRunnerOptions; + Consumer configurator = conf -> {}; + + var componentProperties = restateComponentsProperties.getComponents().get(serviceName); + if (componentProperties != null) { + if (componentProperties.getExecutor() != null) { + if (isKotlinClass) { + throw new IllegalStateException( + "Configured an executor for service " + + serviceName + + " implemented with Kotlin. This is currently not supported, you can set the executor only for Restate java components."); + } + handlerOptions = + createHandlerRunnerOptions(applicationContext, componentProperties.getExecutor()); + } + configurator = conf -> configureService(conf, componentProperties); + } + + // Check the configurator on the annotation as well + RestateComponent restateComponent = + AnnotatedElementUtils.findMergedAnnotation( + restateServiceDefinitionClazz, RestateComponent.class); + if (restateComponent != null && !restateComponent.configuration().isEmpty()) { + var configurationBean = applicationContext.getBean(restateComponent.configuration()); + if (configurationBean instanceof RestateServiceConfigurator) { + configurator = combine(configurator, (RestateServiceConfigurator) configurationBean); + } else if (configurationBean + instanceof RestateComponentProperties componentPropertiesBean) { + if (componentPropertiesBean.getExecutor() != null) { + if (isKotlinClass) { + throw new IllegalStateException( + "Configured an executor for service " + + serviceName + + " implemented with Kotlin. This is currently not supported, you can set the executor only for Restate java components."); + } + handlerOptions = + createHandlerRunnerOptions( + applicationContext, componentPropertiesBean.getExecutor()); + } + configurator = + combine(configurator, conf -> configureService(conf, componentPropertiesBean)); + } + } + + builder.bind(serviceInstance, isKotlinClass ? null : handlerOptions, configurator); + } + if (restateEndpointProperties.isEnablePreviewContext()) { + builder = builder.enablePreviewContext(); + } + if (restateEndpointProperties.getIdentityKey() != null) { + builder.withRequestIdentityVerifier( + RestateRequestIdentityVerifier.fromKey(restateEndpointProperties.getIdentityKey())); + } + return builder.build(); + } + + /** + * Applies the configuration from {@link RestateComponentProperties} to a {@link + * ServiceDefinition.Configurator}. + * + * @param configurator the service configurator to configure + * @param properties the properties to apply + */ + public static void configureService( + ServiceDefinition.Configurator configurator, RestateComponentProperties properties) { + if (properties.getDocumentation() != null) { + configurator.documentation(properties.getDocumentation()); + } + if (properties.getMetadata() != null) { + configurator.metadata(properties.getMetadata()); + } + if (properties.getInactivityTimeout() != null) { + configurator.inactivityTimeout(properties.getInactivityTimeout()); + } + if (properties.getAbortTimeout() != null) { + configurator.abortTimeout(properties.getAbortTimeout()); + } + if (properties.getIdempotencyRetention() != null) { + configurator.idempotencyRetention(properties.getIdempotencyRetention()); + } + if (properties.getWorkflowRetention() != null) { + configurator.workflowRetention(properties.getWorkflowRetention()); + } + if (properties.getJournalRetention() != null) { + configurator.journalRetention(properties.getJournalRetention()); + } + if (properties.getIngressPrivate() != null) { + configurator.ingressPrivate(properties.getIngressPrivate()); + } + if (properties.getEnableLazyState() != null) { + configurator.enableLazyState(properties.getEnableLazyState()); + } + if (properties.getRetryPolicy() != null) { + configurator.invocationRetryPolicy(convertRetryPolicy(properties.getRetryPolicy())); + } + if (properties.getHandlers() != null) { + for (var entry : properties.getHandlers().entrySet()) { + configurator.configureHandler(entry.getKey(), hc -> configureHandler(hc, entry.getValue())); + } + } + } + + private static void configureHandler( + HandlerDefinition.Configurator configurator, RestateHandlerProperties properties) { + if (properties.getDocumentation() != null) { + configurator.documentation(properties.getDocumentation()); + } + if (properties.getMetadata() != null) { + configurator.metadata(properties.getMetadata()); + } + if (properties.getInactivityTimeout() != null) { + configurator.inactivityTimeout(properties.getInactivityTimeout()); + } + if (properties.getAbortTimeout() != null) { + configurator.abortTimeout(properties.getAbortTimeout()); + } + if (properties.getIdempotencyRetention() != null) { + configurator.idempotencyRetention(properties.getIdempotencyRetention()); + } + if (properties.getWorkflowRetention() != null) { + configurator.workflowRetention(properties.getWorkflowRetention()); + } + if (properties.getJournalRetention() != null) { + configurator.journalRetention(properties.getJournalRetention()); + } + if (properties.getIngressPrivate() != null) { + configurator.ingressPrivate(properties.getIngressPrivate()); + } + if (properties.getEnableLazyState() != null) { + configurator.enableLazyState(properties.getEnableLazyState()); + } + if (properties.getRetryPolicy() != null) { + configurator.invocationRetryPolicy(convertRetryPolicy(properties.getRetryPolicy())); + } + } + + private static InvocationRetryPolicy convertRetryPolicy(RetryPolicyProperties properties) { + var builder = InvocationRetryPolicy.builder(); + if (properties.getInitialInterval() != null) { + builder.initialInterval(properties.getInitialInterval()); + } + if (properties.getExponentiationFactor() != null) { + builder.exponentiationFactor(properties.getExponentiationFactor()); + } + if (properties.getMaxInterval() != null) { + builder.maxInterval(properties.getMaxInterval()); + } + if (properties.getMaxAttempts() != null) { + builder.maxAttempts(properties.getMaxAttempts()); + } + if (properties.getOnMaxAttempts() != null) { + builder.onMaxAttempts( + switch (properties.getOnMaxAttempts()) { + case PAUSE -> InvocationRetryPolicy.OnMaxAttempts.PAUSE; + case KILL -> InvocationRetryPolicy.OnMaxAttempts.KILL; + }); + } + return builder.build(); + } + + private static Consumer combine(Consumer consumer1, Consumer consumer2) { + return (T t) -> { + consumer1.accept(t); + consumer2.accept(t); + }; + } + + private static HandlerRunner.@Nullable Options createHandlerRunnerOptions( + ApplicationContext applicationContext, @Nullable String beanExecutorName) { + if (beanExecutorName == null) { + return null; + } + if (WITH_EXECUTOR_METHOD == null) { + throw new IllegalStateException( + "sdk-api module not found. The executor option is only supported for Java services."); + } + Executor executor = applicationContext.getBean(beanExecutorName, Executor.class); + + try { + return (HandlerRunner.Options) WITH_EXECUTOR_METHOD.invoke(null, executor); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to create HandlerRunner.Options", e); + } + } +} diff --git a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateHandlerProperties.java b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateHandlerProperties.java new file mode 100644 index 000000000..dd0b317cc --- /dev/null +++ b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateHandlerProperties.java @@ -0,0 +1,350 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.springboot; + +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Configuration properties for a Restate handler. + * + *

These properties can be used to configure individual handlers within a Restate service via + * Spring configuration files. + */ +public class RestateHandlerProperties { + + @Nullable private String documentation; + @Nullable private Map metadata; + @Nullable private Duration inactivityTimeout; + @Nullable private Duration abortTimeout; + @Nullable private Duration idempotencyRetention; + @Nullable private Duration workflowRetention; + @Nullable private Duration journalRetention; + @Nullable private Boolean ingressPrivate; + @Nullable private Boolean enableLazyState; + @Nullable private RetryPolicyProperties retryPolicy; + + public RestateHandlerProperties() {} + + public RestateHandlerProperties( + @Nullable String documentation, + @Nullable Map metadata, + @Nullable Duration inactivityTimeout, + @Nullable Duration abortTimeout, + @Nullable Duration idempotencyRetention, + @Nullable Duration workflowRetention, + @Nullable Duration journalRetention, + @Nullable Boolean ingressPrivate, + @Nullable Boolean enableLazyState, + @Nullable RetryPolicyProperties retryPolicy) { + this.documentation = documentation; + this.metadata = metadata; + this.inactivityTimeout = inactivityTimeout; + this.abortTimeout = abortTimeout; + this.idempotencyRetention = idempotencyRetention; + this.workflowRetention = workflowRetention; + this.journalRetention = journalRetention; + this.ingressPrivate = ingressPrivate; + this.enableLazyState = enableLazyState; + this.retryPolicy = retryPolicy; + } + + /** + * Documentation as shown in the UI, Admin REST API, and the generated OpenAPI documentation of + * this handler. + */ + public @Nullable String getDocumentation() { + return documentation; + } + + /** + * Documentation as shown in the UI, Admin REST API, and the generated OpenAPI documentation of + * this handler. + */ + public void setDocumentation(@Nullable String documentation) { + this.documentation = documentation; + } + + /** Handler metadata, as propagated in the Admin REST API. */ + public @Nullable Map getMetadata() { + return metadata; + } + + /** Handler metadata, as propagated in the Admin REST API. */ + public void setMetadata(@Nullable Map metadata) { + this.metadata = metadata; + } + + /** + * This timer guards against stalled invocations. Once it expires, Restate triggers a graceful + * termination by asking the invocation to suspend (which preserves intermediate progress). + * + *

The {@link #getAbortTimeout()} is used to abort the invocation, in case it doesn't react to + * the request to suspend. + * + *

This overrides the inactivity timeout set for the service and the default set in + * restate-server. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public @Nullable Duration getInactivityTimeout() { + return inactivityTimeout; + } + + /** + * This timer guards against stalled invocations. Once it expires, Restate triggers a graceful + * termination by asking the invocation to suspend (which preserves intermediate progress). + * + *

The {@link #getAbortTimeout()} is used to abort the invocation, in case it doesn't react to + * the request to suspend. + * + *

This overrides the inactivity timeout set for the service and the default set in + * restate-server. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public void setInactivityTimeout(@Nullable Duration inactivityTimeout) { + this.inactivityTimeout = inactivityTimeout; + } + + /** + * This timer guards against stalled invocations that are supposed to terminate. The abort timeout + * is started after the {@link #getInactivityTimeout()} has expired and the invocation has been + * asked to gracefully terminate. Once the timer expires, it will abort the invocation. + * + *

This timer potentially interrupts user code. If the user code needs longer to + * gracefully terminate, then this value needs to be set accordingly. + * + *

This overrides the abort timeout set for the service and the default set in restate-server. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public @Nullable Duration getAbortTimeout() { + return abortTimeout; + } + + /** + * This timer guards against stalled invocations that are supposed to terminate. The abort timeout + * is started after the {@link #getInactivityTimeout()} has expired and the invocation has been + * asked to gracefully terminate. Once the timer expires, it will abort the invocation. + * + *

This timer potentially interrupts user code. If the user code needs longer to + * gracefully terminate, then this value needs to be set accordingly. + * + *

This overrides the abort timeout set for the service and the default set in restate-server. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public void setAbortTimeout(@Nullable Duration abortTimeout) { + this.abortTimeout = abortTimeout; + } + + /** + * The retention duration of idempotent requests to this handler. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + * + *

NOTE: This cannot be set for workflow handlers. Use {@link #getWorkflowRetention()} + * instead. + */ + public @Nullable Duration getIdempotencyRetention() { + return idempotencyRetention; + } + + /** + * The retention duration of idempotent requests to this handler. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + * + *

NOTE: This cannot be set for workflow handlers. Use {@link #setWorkflowRetention} + * instead. + */ + public void setIdempotencyRetention(@Nullable Duration idempotencyRetention) { + this.idempotencyRetention = idempotencyRetention; + } + + /** + * The retention duration for workflow handlers. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + * + *

NOTE: This can only be set for workflow handlers. + */ + public @Nullable Duration getWorkflowRetention() { + return workflowRetention; + } + + /** + * The retention duration for workflow handlers. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + * + *

NOTE: This can only be set for workflow handlers. + */ + public void setWorkflowRetention(@Nullable Duration workflowRetention) { + this.workflowRetention = workflowRetention; + } + + /** + * The journal retention for invocations to this handler. + * + *

In case the request has an idempotency key, the {@link #getIdempotencyRetention()} caps the + * journal retention time. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public @Nullable Duration getJournalRetention() { + return journalRetention; + } + + /** + * The journal retention for invocations to this handler. + * + *

In case the request has an idempotency key, the {@link #getIdempotencyRetention()} caps the + * journal retention time. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public void setJournalRetention(@Nullable Duration journalRetention) { + this.journalRetention = journalRetention; + } + + /** + * When set to {@code true} this handler cannot be invoked from the restate-server HTTP and Kafka + * ingress, but only from other services. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public @Nullable Boolean getIngressPrivate() { + return ingressPrivate; + } + + /** + * When set to {@code true} this handler cannot be invoked from the restate-server HTTP and Kafka + * ingress, but only from other services. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public void setIngressPrivate(@Nullable Boolean ingressPrivate) { + this.ingressPrivate = ingressPrivate; + } + + /** + * When set to {@code true}, lazy state will be enabled for all invocations to this handler. This + * is relevant only for workflows and virtual objects. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public @Nullable Boolean getEnableLazyState() { + return enableLazyState; + } + + /** + * When set to {@code true}, lazy state will be enabled for all invocations to this handler. This + * is relevant only for workflows and virtual objects. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.4, otherwise the service discovery will fail. + */ + public void setEnableLazyState(@Nullable Boolean enableLazyState) { + this.enableLazyState = enableLazyState; + } + + /** + * Retry policy used by Restate when invoking this handler. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.5, otherwise the service discovery will fail. + */ + public @Nullable RetryPolicyProperties getRetryPolicy() { + return retryPolicy; + } + + /** + * Retry policy used by Restate when invoking this handler. + * + *

NOTE: You can set this field only if you register this service against restate-server + * >= 1.5, otherwise the service discovery will fail. + */ + public void setRetryPolicy(@Nullable RetryPolicyProperties retryPolicy) { + this.retryPolicy = retryPolicy; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof RestateHandlerProperties that)) return false; + return Objects.equals(getDocumentation(), that.getDocumentation()) + && Objects.equals(getMetadata(), that.getMetadata()) + && Objects.equals(getInactivityTimeout(), that.getInactivityTimeout()) + && Objects.equals(getAbortTimeout(), that.getAbortTimeout()) + && Objects.equals(getIdempotencyRetention(), that.getIdempotencyRetention()) + && Objects.equals(getWorkflowRetention(), that.getWorkflowRetention()) + && Objects.equals(getJournalRetention(), that.getJournalRetention()) + && Objects.equals(getIngressPrivate(), that.getIngressPrivate()) + && Objects.equals(getEnableLazyState(), that.getEnableLazyState()) + && Objects.equals(getRetryPolicy(), that.getRetryPolicy()); + } + + @Override + public int hashCode() { + return Objects.hash( + getDocumentation(), + getMetadata(), + getInactivityTimeout(), + getAbortTimeout(), + getIdempotencyRetention(), + getWorkflowRetention(), + getJournalRetention(), + getIngressPrivate(), + getEnableLazyState(), + getRetryPolicy()); + } + + @Override + public String toString() { + return "RestateHandlerProperties{" + + "documentation='" + + documentation + + '\'' + + ", metadata=" + + metadata + + ", inactivityTimeout=" + + inactivityTimeout + + ", abortTimeout=" + + abortTimeout + + ", idempotencyRetention=" + + idempotencyRetention + + ", workflowRetention=" + + workflowRetention + + ", journalRetention=" + + journalRetention + + ", ingressPrivate=" + + ingressPrivate + + ", enableLazyState=" + + enableLazyState + + ", retryPolicy=" + + retryPolicy + + '}'; + } +} diff --git a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateHttpConfiguration.java b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateHttpConfiguration.java new file mode 100644 index 000000000..c7a104398 --- /dev/null +++ b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateHttpConfiguration.java @@ -0,0 +1,36 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.springboot; + +import dev.restate.sdk.endpoint.Endpoint; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(RestateHttpServerProperties.class) +public class RestateHttpConfiguration { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @Nullable + @Bean + RestateHttpEndpointBean restateHttpEndpointBean( + @Nullable Endpoint endpoint, RestateHttpServerProperties restateHttpServerProperties) { + if (endpoint == null) { + logger.info("No Endpoint was injected, SDK server will not start"); + // Don't start anything if no service is registered + return null; + } + return new RestateHttpEndpointBean(endpoint, restateHttpServerProperties); + } +} diff --git a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateHttpEndpointBean.java b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateHttpEndpointBean.java index 220328174..24e465d2a 100644 --- a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateHttpEndpointBean.java +++ b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateHttpEndpointBean.java @@ -17,46 +17,42 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; -import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.ApplicationContext; import org.springframework.context.SmartLifecycle; -import org.springframework.stereotype.Component; - -/** - * Restate HTTP Endpoint serving {@link RestateComponent}. - * - * @see Component - */ -@Component -@EnableConfigurationProperties({RestateHttpServerProperties.class, RestateEndpointProperties.class}) + +/** Restate HTTP Endpoint serving {@link Endpoint} */ public class RestateHttpEndpointBean implements InitializingBean, SmartLifecycle { private final Logger logger = LoggerFactory.getLogger(getClass()); - private final ApplicationContext applicationContext; - private final RestateEndpointProperties restateEndpointProperties; private final RestateHttpServerProperties restateHttpServerProperties; private volatile boolean running; - private HttpServer server; + private final HttpServer server; + + public RestateHttpEndpointBean( + Endpoint endpoint, RestateHttpServerProperties restateHttpServerProperties) { + this.restateHttpServerProperties = restateHttpServerProperties; + this.server = + RestateHttpServer.fromHandler( + HttpEndpointRequestHandler.fromEndpoint( + endpoint, this.restateHttpServerProperties.isDisableBidirectionalStreaming())); + } + @Deprecated public RestateHttpEndpointBean( ApplicationContext applicationContext, RestateEndpointProperties restateEndpointProperties, RestateHttpServerProperties restateHttpServerProperties) { - this.applicationContext = applicationContext; - this.restateEndpointProperties = restateEndpointProperties; this.restateHttpServerProperties = restateHttpServerProperties; - } - @Override - public void afterPropertiesSet() { Map restateComponents = applicationContext.getBeansWithAnnotation(RestateComponent.class); if (restateComponents.isEmpty()) { logger.info("No @RestateComponent discovered"); + this.server = null; // Don't start anything, if no service is registered return; } @@ -87,41 +83,40 @@ public void afterPropertiesSet() { this.server = RestateHttpServer.fromHandler( HttpEndpointRequestHandler.fromEndpoint( - builder.build(), - this.restateHttpServerProperties.isDisableBidirectionalStreaming())); + builder.build(), restateHttpServerProperties.isDisableBidirectionalStreaming())); } + @Deprecated + @Override + public void afterPropertiesSet() {} + @Override public void start() { - if (this.server != null) { - try { - this.server - .listen(this.restateHttpServerProperties.getPort()) - .toCompletionStage() - .toCompletableFuture() - .get(); - logger.info("Started Restate Spring HTTP server on port {}", this.server.actualPort()); - } catch (Exception e) { - logger.error( - "Error when starting Restate Spring HTTP server on port {}", - this.restateHttpServerProperties.getPort(), - e); - } - this.running = true; + try { + this.server + .listen(this.restateHttpServerProperties.getPort()) + .toCompletionStage() + .toCompletableFuture() + .get(); + logger.info("Started Restate Spring HTTP server on port {}", this.server.actualPort()); + } catch (Exception e) { + logger.error( + "Error when starting Restate Spring HTTP server on port {}", + this.restateHttpServerProperties.getPort(), + e); } + this.running = true; } @Override public void stop() { - if (this.server != null) { - try { - this.server.close().toCompletionStage().toCompletableFuture().get(); - logger.info("Stopped Restate Spring HTTP server"); - } catch (Exception e) { - logger.error("Error when stopping the Restate Spring HTTP server", e); - } - this.running = false; + try { + this.server.close().toCompletionStage().toCompletableFuture().get(); + logger.info("Stopped Restate Spring HTTP server"); + } catch (Exception e) { + logger.error("Error when stopping the Restate Spring HTTP server", e); } + this.running = false; } @Override @@ -130,7 +125,7 @@ public boolean isRunning() { } public int actualPort() { - if (this.server == null) { + if (!this.isRunning()) { return -1; } return this.server.actualPort(); diff --git a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateService.java b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateService.java index 6163fed15..7fb00c6cf 100644 --- a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateService.java +++ b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateService.java @@ -27,8 +27,9 @@ public @interface RestateService { /** - * Bean name to use to configure this service. The bean MUST be an instance of {@link - * RestateServiceConfigurator}. + * Bean name to use to configure this component. + * + *

The bean MUST be an instance of {@link RestateComponentProperties}. */ @AliasFor(annotation = RestateComponent.class, attribute = "configuration") String configuration() default ""; diff --git a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateServiceConfigurator.java b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateServiceConfigurator.java index a46dee4cb..ba1c11b89 100644 --- a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateServiceConfigurator.java +++ b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateServiceConfigurator.java @@ -11,6 +11,13 @@ import dev.restate.sdk.endpoint.definition.ServiceDefinition; import java.util.function.Consumer; -/** Service configurator to be registered as */ +/** + * Service configurator to be registered as + * + * @deprecated Use {@link RestateComponentProperties} instead, or directly define the configuration + * in your {@code application.properties} file. See {@link RestateComponentsProperties} for more + * info. + */ @FunctionalInterface +@Deprecated public interface RestateServiceConfigurator extends Consumer {} diff --git a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateVirtualObject.java b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateVirtualObject.java index f498f3e9f..1487dbc6f 100644 --- a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateVirtualObject.java +++ b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateVirtualObject.java @@ -28,8 +28,9 @@ public @interface RestateVirtualObject { /** - * Bean name to use to configure this virtual object. The bean MUST be an instance of {@link - * RestateServiceConfigurator}. + * Bean name to use to configure this component. + * + *

The bean MUST be an instance of {@link RestateComponentProperties}. */ @AliasFor(annotation = RestateComponent.class, attribute = "configuration") String configuration() default ""; diff --git a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateWorkflow.java b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateWorkflow.java index 631959e55..85bf7ed93 100644 --- a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateWorkflow.java +++ b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RestateWorkflow.java @@ -28,8 +28,9 @@ public @interface RestateWorkflow { /** - * Bean name to use to configure this workflow. The bean MUST be an instance of {@link - * RestateServiceConfigurator}. + * Bean name to use to configure this component. + * + *

The bean MUST be an instance of {@link RestateComponentProperties}. */ @AliasFor(annotation = RestateComponent.class, attribute = "configuration") String configuration() default ""; diff --git a/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RetryPolicyProperties.java b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RetryPolicyProperties.java new file mode 100644 index 000000000..3f9c79e07 --- /dev/null +++ b/sdk-spring-boot/src/main/java/dev/restate/sdk/springboot/RetryPolicyProperties.java @@ -0,0 +1,203 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.springboot; + +import java.time.Duration; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Configuration properties for Restate's retry policy when retrying failed handler invocations. + * + *

This policy controls an exponential backoff with optional capping and a terminal action: + * + *

    + *
  • {@code initialInterval}: delay before the first retry attempt. + *
  • {@code exponentiationFactor}: multiplier applied to the previous delay to compute the next + * delay. + *
  • {@code maxInterval}: upper bound for any computed delay. + *
  • {@code maxAttempts}: maximum number of attempts (initial call counts as the first attempt). + *
  • {@code onMaxAttempts}: what to do when {@code maxAttempts} is reached. + *
+ * + *

Unset fields inherit the corresponding defaults from the Restate server configuration. + * + * @see dev.restate.sdk.endpoint.definition.InvocationRetryPolicy + */ +public class RetryPolicyProperties { + + /** Behavior when retry policy reaches {@link #getMaxAttempts()} attempts. */ + public enum OnMaxAttempts { + /** + * Pause the invocation once retries are exhausted. The invocation enters the paused state and + * can be manually resumed from the CLI or UI. + */ + PAUSE, + /** + * Kill the invocation once retries are exhausted. The invocation will be marked as failed and + * will not be retried unless explicitly re-triggered by the caller. + */ + KILL + } + + @Nullable private Duration initialInterval; + @Nullable private Double exponentiationFactor; + @Nullable private Duration maxInterval; + @Nullable private Integer maxAttempts; + @Nullable private OnMaxAttempts onMaxAttempts; + + public RetryPolicyProperties() {} + + public RetryPolicyProperties( + @Nullable Duration initialInterval, + @Nullable Double exponentiationFactor, + @Nullable Duration maxInterval, + @Nullable Integer maxAttempts, + @Nullable OnMaxAttempts onMaxAttempts) { + this.initialInterval = initialInterval; + this.exponentiationFactor = exponentiationFactor; + this.maxInterval = maxInterval; + this.maxAttempts = maxAttempts; + this.onMaxAttempts = onMaxAttempts; + } + + /** + * Initial delay before the first retry attempt. + * + *

If unset, the server default is used. + */ + public @Nullable Duration getInitialInterval() { + return initialInterval; + } + + /** + * Initial delay before the first retry attempt. + * + *

If unset, the server default is used. + */ + public void setInitialInterval(@Nullable Duration initialInterval) { + this.initialInterval = initialInterval; + } + + /** + * Exponential backoff multiplier used to compute the next retry delay. + * + *

For attempt {@code n}, the next delay is roughly {@code previousDelay * + * exponentiationFactor}, capped by {@link #getMaxInterval()} if set. + */ + public @Nullable Double getExponentiationFactor() { + return exponentiationFactor; + } + + /** + * Exponential backoff multiplier used to compute the next retry delay. + * + *

For attempt {@code n}, the next delay is roughly {@code previousDelay * + * exponentiationFactor}, capped by {@link #getMaxInterval()} if set. + */ + public void setExponentiationFactor(@Nullable Double exponentiationFactor) { + this.exponentiationFactor = exponentiationFactor; + } + + /** + * Upper bound for the computed retry delay. + * + *

If set, any computed delay will not exceed this value. + */ + public @Nullable Duration getMaxInterval() { + return maxInterval; + } + + /** + * Upper bound for the computed retry delay. + * + *

If set, any computed delay will not exceed this value. + */ + public void setMaxInterval(@Nullable Duration maxInterval) { + this.maxInterval = maxInterval; + } + + /** + * Maximum number of attempts before giving up retrying. + * + *

The initial call counts as the first attempt; retries increment the count by 1. When giving + * up, the behavior defined with {@link #getOnMaxAttempts()} will be applied. + * + * @see OnMaxAttempts + */ + public @Nullable Integer getMaxAttempts() { + return maxAttempts; + } + + /** + * Maximum number of attempts before giving up retrying. + * + *

The initial call counts as the first attempt; retries increment the count by 1. When giving + * up, the behavior defined with {@link #getOnMaxAttempts()} will be applied. + * + * @see OnMaxAttempts + */ + public void setMaxAttempts(@Nullable Integer maxAttempts) { + this.maxAttempts = maxAttempts; + } + + /** + * Behavior when the configured {@link #getMaxAttempts()} is reached. + * + * @see OnMaxAttempts + */ + public @Nullable OnMaxAttempts getOnMaxAttempts() { + return onMaxAttempts; + } + + /** + * Behavior when the configured {@link #getMaxAttempts()} is reached. + * + * @see OnMaxAttempts + */ + public void setOnMaxAttempts(@Nullable OnMaxAttempts onMaxAttempts) { + this.onMaxAttempts = onMaxAttempts; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof RetryPolicyProperties that)) return false; + return Objects.equals(getInitialInterval(), that.getInitialInterval()) + && Objects.equals(getExponentiationFactor(), that.getExponentiationFactor()) + && Objects.equals(getMaxInterval(), that.getMaxInterval()) + && Objects.equals(getMaxAttempts(), that.getMaxAttempts()) + && getOnMaxAttempts() == that.getOnMaxAttempts(); + } + + @Override + public int hashCode() { + return Objects.hash( + getInitialInterval(), + getExponentiationFactor(), + getMaxInterval(), + getMaxAttempts(), + getOnMaxAttempts()); + } + + @Override + public String toString() { + return "RetryPolicyProperties{" + + "initialInterval=" + + initialInterval + + ", exponentiationFactor=" + + exponentiationFactor + + ", maxInterval=" + + maxInterval + + ", maxAttempts=" + + maxAttempts + + ", onMaxAttempts=" + + onMaxAttempts + + '}'; + } +}