From d0875732baccfb8b494419ffac0ea21cc2474e00 Mon Sep 17 00:00:00 2001 From: Chris Constable Date: Thu, 4 Jun 2026 15:38:01 -0400 Subject: [PATCH 1/8] Add initial extstore types. --- .../payload/storage/ExternalStorage.java | 95 +++++++++++++++++++ .../payload/storage/StorageDriver.java | 45 +++++++++ .../storage/StorageDriverActivityInfo.java | 45 +++++++++ .../payload/storage/StorageDriverClaim.java | 28 ++++++ .../storage/StorageDriverRetrieveContext.java | 7 ++ .../storage/StorageDriverSelector.java | 18 ++++ .../storage/StorageDriverStoreContext.java | 19 ++++ .../storage/StorageDriverTargetInfo.java | 11 +++ .../storage/StorageDriverWorkflowInfo.java | 45 +++++++++ .../payload/storage/ExternalStorageTest.java | 89 +++++++++++++++++ 10 files changed, 402 insertions(+) create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverClaim.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverRetrieveContext.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelector.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverStoreContext.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverTargetInfo.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java create mode 100644 temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java new file mode 100644 index 0000000000..0cbcd56509 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java @@ -0,0 +1,95 @@ +package io.temporal.payload.storage; + +import com.google.common.base.Preconditions; +import io.temporal.common.Experimental; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** Configuration for offloading large payloads to external storage. */ +@Experimental +public final class ExternalStorage { + private static final int DEFAULT_PAYLOAD_SIZE_THRESHOLD = 256 * 1024; + + public static Builder newBuilder() { + return new Builder(); + } + + private final @Nonnull List drivers; + private final @Nullable StorageDriverSelector driverSelector; + private final @Nullable Integer payloadSizeThreshold; + + private ExternalStorage( + @Nonnull List drivers, + @Nullable StorageDriverSelector driverSelector, + @Nullable Integer payloadSizeThreshold) { + this.drivers = Collections.unmodifiableList(new ArrayList<>(drivers)); + this.driverSelector = driverSelector; + this.payloadSizeThreshold = payloadSizeThreshold; + } + + @Nonnull + public List getDrivers() { + return drivers; + } + + @Nullable + public StorageDriverSelector getDriverSelector() { + return driverSelector; + } + + /** + * Minimum payload size in bytes before external storage is considered; {@code null} stores all. + */ + @Nullable + public Integer getPayloadSizeThreshold() { + return payloadSizeThreshold; + } + + public static final class Builder { + private List drivers = Collections.emptyList(); + private StorageDriverSelector driverSelector; + private Integer payloadSizeThreshold = DEFAULT_PAYLOAD_SIZE_THRESHOLD; + + private Builder() {} + + /** At least one driver is required. When more than one is set, a selector is also required. */ + public Builder setDrivers(@Nonnull List drivers) { + this.drivers = Objects.requireNonNull(drivers, "drivers"); + return this; + } + + public Builder setDriverSelector(@Nullable StorageDriverSelector driverSelector) { + this.driverSelector = driverSelector; + return this; + } + + /** Defaults to 256 KiB. Set to {@code null} to store all payloads. */ + public Builder setPayloadSizeThreshold(@Nullable Integer payloadSizeThreshold) { + this.payloadSizeThreshold = payloadSizeThreshold; + return this; + } + + public ExternalStorage build() { + Preconditions.checkArgument(!drivers.isEmpty(), "At least one driver must be provided"); + Preconditions.checkArgument( + payloadSizeThreshold == null || payloadSizeThreshold >= 0, + "payloadSizeThreshold must be greater than or equal to zero"); + Set names = new HashSet<>(); + for (StorageDriver driver : drivers) { + String name = driver.getName(); + Preconditions.checkArgument( + names.add(name), "Multiple drivers registered with name '%s'", name); + } + Preconditions.checkArgument( + drivers.size() == 1 || driverSelector != null, + "driverSelector must be specified when more than one driver is registered"); + return new ExternalStorage(drivers, driverSelector, payloadSizeThreshold); + } + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java new file mode 100644 index 0000000000..dda0ec567b --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java @@ -0,0 +1,45 @@ +package io.temporal.payload.storage; + +import io.temporal.api.common.v1.Payload; +import io.temporal.common.Experimental; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; + +/** Stores and retrieves payloads in an external storage system. */ +@Experimental +public interface StorageDriver { + /** + * Name of this driver instance, unique among the drivers registered in a single {@link + * ExternalStorage}. Used as the routing key recorded in a stored payload's reference and resolved + * back to this driver on retrieval. + */ + @Nonnull + String getName(); + + /** + * Stable, implementation-level identifier for this driver, the same across all instances of the + * driver class and ideally across SDKs (e.g. {@code "aws.s3driver"}). Used for metrics and worker + * heartbeat reporting. + */ + @Nonnull + default String getType() { + return getClass().getName(); + } + + /** + * Stores {@code payloads} and returns one {@link StorageDriverClaim} per payload, in the same + * order. The returned list must be the same length as {@code payloads}. + */ + @Nonnull + CompletableFuture> store( + @Nonnull StorageDriverStoreContext context, @Nonnull List payloads); + + /** + * Retrieves the payloads identified by {@code claims} and returns one {@link Payload} per claim, + * in the same order. The returned list must be the same length as {@code claims}. + */ + @Nonnull + CompletableFuture> retrieve( + @Nonnull StorageDriverRetrieveContext context, @Nonnull List claims); +} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java new file mode 100644 index 0000000000..7d22f783df --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java @@ -0,0 +1,45 @@ +package io.temporal.payload.storage; + +import io.temporal.common.Experimental; +import java.util.Objects; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +@Experimental +public final class StorageDriverActivityInfo implements StorageDriverTargetInfo { + private final @Nonnull String namespace; + private final @Nullable String id; + private final @Nullable String runId; + private final @Nullable String type; + + public StorageDriverActivityInfo( + @Nonnull String namespace, + @Nullable String id, + @Nullable String runId, + @Nullable String type) { + this.namespace = Objects.requireNonNull(namespace, "namespace"); + this.id = id; + this.runId = runId; + this.type = type; + } + + @Nonnull + public String getNamespace() { + return namespace; + } + + @Nullable + public String getId() { + return id; + } + + @Nullable + public String getRunId() { + return runId; + } + + @Nullable + public String getType() { + return type; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverClaim.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverClaim.java new file mode 100644 index 0000000000..64996375e6 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverClaim.java @@ -0,0 +1,28 @@ +package io.temporal.payload.storage; + +import io.temporal.common.Experimental; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import javax.annotation.Nonnull; + +/** + * Driver-defined reference to an externally stored payload, used to retrieve it later. + * + * @see StorageDriver + */ +@Experimental +public final class StorageDriverClaim { + private final @Nonnull Map claimData; + + public StorageDriverClaim(@Nonnull Map claimData) { + this.claimData = + Collections.unmodifiableMap(new HashMap<>(Objects.requireNonNull(claimData, "claimData"))); + } + + @Nonnull + public Map getClaimData() { + return claimData; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverRetrieveContext.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverRetrieveContext.java new file mode 100644 index 0000000000..d83e03d578 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverRetrieveContext.java @@ -0,0 +1,7 @@ +package io.temporal.payload.storage; + +import io.temporal.common.Experimental; + +/** Context passed to {@link StorageDriver#retrieve}. */ +@Experimental +public final class StorageDriverRetrieveContext {} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelector.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelector.java new file mode 100644 index 0000000000..966e52e68d --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelector.java @@ -0,0 +1,18 @@ +package io.temporal.payload.storage; + +import io.temporal.api.common.v1.Payload; +import io.temporal.common.Experimental; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** Chooses which {@link StorageDriver} stores a given payload. */ +@Experimental +@FunctionalInterface +public interface StorageDriverSelector { + /** + * Returns the driver to store {@code payload}, which must be one of the drivers registered in the + * {@link ExternalStorage}, or {@code null} to leave the payload stored inline. + */ + @Nullable + StorageDriver selectDriver(@Nonnull StorageDriverStoreContext context, @Nonnull Payload payload); +} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverStoreContext.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverStoreContext.java new file mode 100644 index 0000000000..732a7de08a --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverStoreContext.java @@ -0,0 +1,19 @@ +package io.temporal.payload.storage; + +import io.temporal.common.Experimental; +import javax.annotation.Nullable; + +/** Context passed to {@link StorageDriver#store} and {@link StorageDriverSelector}. */ +@Experimental +public final class StorageDriverStoreContext { + private final @Nullable StorageDriverTargetInfo target; + + public StorageDriverStoreContext(@Nullable StorageDriverTargetInfo target) { + this.target = target; + } + + @Nullable + public StorageDriverTargetInfo getTarget() { + return target; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverTargetInfo.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverTargetInfo.java new file mode 100644 index 0000000000..f70bc08ed1 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverTargetInfo.java @@ -0,0 +1,11 @@ +package io.temporal.payload.storage; + +import io.temporal.common.Experimental; + +/** + * Identity of the workflow or activity a payload is being stored on behalf of. Provided on a + * best-effort basis on the storing side only; some fields may be absent. Implemented by {@link + * StorageDriverWorkflowInfo} and {@link StorageDriverActivityInfo}. + */ +@Experimental +public interface StorageDriverTargetInfo {} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java new file mode 100644 index 0000000000..c74f8db7ce --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java @@ -0,0 +1,45 @@ +package io.temporal.payload.storage; + +import io.temporal.common.Experimental; +import java.util.Objects; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +@Experimental +public final class StorageDriverWorkflowInfo implements StorageDriverTargetInfo { + private final @Nonnull String namespace; + private final @Nullable String id; + private final @Nullable String runId; + private final @Nullable String type; + + public StorageDriverWorkflowInfo( + @Nonnull String namespace, + @Nullable String id, + @Nullable String runId, + @Nullable String type) { + this.namespace = Objects.requireNonNull(namespace, "namespace"); + this.id = id; + this.runId = runId; + this.type = type; + } + + @Nonnull + public String getNamespace() { + return namespace; + } + + @Nullable + public String getId() { + return id; + } + + @Nullable + public String getRunId() { + return runId; + } + + @Nullable + public String getType() { + return type; + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java new file mode 100644 index 0000000000..3d553d6e56 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java @@ -0,0 +1,89 @@ +package io.temporal.payload.storage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import io.temporal.api.common.v1.Payload; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.Test; + +public class ExternalStorageTest { + + private static StorageDriver driver(String name) { + return new StorageDriver() { + @Override + public String getName() { + return name; + } + + @Override + public CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + throw new UnsupportedOperationException(); + } + }; + } + + @Test + public void singleDriverNoSelectorIsValid() { + ExternalStorage storage = + ExternalStorage.newBuilder().setDrivers(Collections.singletonList(driver("a"))).build(); + assertEquals(1, storage.getDrivers().size()); + assertNull(storage.getDriverSelector()); + } + + @Test + public void multipleDriversWithSelectorIsValid() { + StorageDriver a = driver("a"); + ExternalStorage storage = + ExternalStorage.newBuilder() + .setDrivers(Arrays.asList(a, driver("b"))) + .setDriverSelector((context, payload) -> a) + .build(); + assertEquals(2, storage.getDrivers().size()); + assertNotNull(storage.getDriverSelector()); + } + + @Test + public void nullThresholdStoresAll() { + ExternalStorage storage = + ExternalStorage.newBuilder() + .setDrivers(Collections.singletonList(driver("a"))) + .setPayloadSizeThreshold(null) + .build(); + assertNull(storage.getPayloadSizeThreshold()); + } + + @Test(expected = IllegalArgumentException.class) + public void noDriversRejected() { + ExternalStorage.newBuilder().build(); + } + + @Test(expected = IllegalArgumentException.class) + public void duplicateDriverNamesRejected() { + ExternalStorage.newBuilder().setDrivers(Arrays.asList(driver("dup"), driver("dup"))).build(); + } + + @Test(expected = IllegalArgumentException.class) + public void multipleDriversRequireSelector() { + ExternalStorage.newBuilder().setDrivers(Arrays.asList(driver("a"), driver("b"))).build(); + } + + @Test(expected = IllegalArgumentException.class) + public void negativeThresholdRejected() { + ExternalStorage.newBuilder() + .setDrivers(Collections.singletonList(driver("a"))) + .setPayloadSizeThreshold(-1) + .build(); + } +} From 3d3a5b07e8ce7b070cb4b09576e7a37003aaf728 Mon Sep 17 00:00:00 2001 From: Chris Constable Date: Thu, 4 Jun 2026 15:58:57 -0400 Subject: [PATCH 2/8] Synthesize extstore driver if only one driver is given with no selector to remove nullable types. --- .../payload/storage/ExternalStorage.java | 18 ++++++++++++------ .../storage/StorageDriverActivityInfo.java | 5 +++++ .../storage/StorageDriverWorkflowInfo.java | 5 +++++ .../payload/storage/ExternalStorageTest.java | 12 +++++++++--- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java index 0cbcd56509..d99f3fcca5 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java @@ -21,12 +21,12 @@ public static Builder newBuilder() { } private final @Nonnull List drivers; - private final @Nullable StorageDriverSelector driverSelector; + private final @Nonnull StorageDriverSelector driverSelector; private final @Nullable Integer payloadSizeThreshold; private ExternalStorage( @Nonnull List drivers, - @Nullable StorageDriverSelector driverSelector, + @Nonnull StorageDriverSelector driverSelector, @Nullable Integer payloadSizeThreshold) { this.drivers = Collections.unmodifiableList(new ArrayList<>(drivers)); this.driverSelector = driverSelector; @@ -38,13 +38,14 @@ public List getDrivers() { return drivers; } - @Nullable + @Nonnull public StorageDriverSelector getDriverSelector() { return driverSelector; } /** - * Minimum payload size in bytes before external storage is considered; {@code null} stores all. + * Minimum payload size in bytes before external storage is considered. + * {@code null} stores all. Defaults to 256 KiB. */ @Nullable public Integer getPayloadSizeThreshold() { @@ -69,7 +70,7 @@ public Builder setDriverSelector(@Nullable StorageDriverSelector driverSelector) return this; } - /** Defaults to 256 KiB. Set to {@code null} to store all payloads. */ + /** Set to {@code null} to store all payloads. Defaults to 256 KiB. */ public Builder setPayloadSizeThreshold(@Nullable Integer payloadSizeThreshold) { this.payloadSizeThreshold = payloadSizeThreshold; return this; @@ -89,7 +90,12 @@ public ExternalStorage build() { Preconditions.checkArgument( drivers.size() == 1 || driverSelector != null, "driverSelector must be specified when more than one driver is registered"); - return new ExternalStorage(drivers, driverSelector, payloadSizeThreshold); + StorageDriverSelector selector = driverSelector; + if (selector == null) { + StorageDriver driver = drivers.get(0); + selector = (context, payload) -> driver; + } + return new ExternalStorage(drivers, selector, payloadSizeThreshold); } } } diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java index 7d22f783df..fd0dc92a04 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java @@ -5,6 +5,11 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; +/** + * Identity of the activity a payload is being stored on behalf of. Provided to a {@link + * StorageDriver} via {@link StorageDriverStoreContext#getTarget()}. All fields except {@code + * namespace} are best-effort and may be {@code null} when not available at store time. + */ @Experimental public final class StorageDriverActivityInfo implements StorageDriverTargetInfo { private final @Nonnull String namespace; diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java index c74f8db7ce..a13225f103 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java @@ -5,6 +5,11 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; +/** + * Identity of the workflow a payload is being stored on behalf of. Provided to a {@link + * StorageDriver} via {@link StorageDriverStoreContext#getTarget()}. All fields except {@code + * namespace} are best-effort and may be {@code null} when not available at store time. + */ @Experimental public final class StorageDriverWorkflowInfo implements StorageDriverTargetInfo { private final @Nonnull String namespace; diff --git a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java index 3d553d6e56..7b1e085bc0 100644 --- a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java +++ b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java @@ -3,6 +3,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import io.temporal.api.common.v1.Payload; import java.util.Arrays; @@ -35,11 +36,16 @@ public CompletableFuture> retrieve( } @Test - public void singleDriverNoSelectorIsValid() { + public void singleDriverNoSelectorSynthesizesSelector() { + StorageDriver a = driver("a"); ExternalStorage storage = - ExternalStorage.newBuilder().setDrivers(Collections.singletonList(driver("a"))).build(); + ExternalStorage.newBuilder().setDrivers(Collections.singletonList(a)).build(); assertEquals(1, storage.getDrivers().size()); - assertNull(storage.getDriverSelector()); + StorageDriverSelector selector = storage.getDriverSelector(); + assertNotNull(selector); + assertSame( + a, + selector.selectDriver(new StorageDriverStoreContext(null), Payload.getDefaultInstance())); } @Test From 0328e776d68eea3dfda58cfb138df3c7d7337d58 Mon Sep 17 00:00:00 2001 From: Chris Constable Date: Thu, 4 Jun 2026 16:03:29 -0400 Subject: [PATCH 3/8] Remove nullable payloadSizeThreshold. 0 means store everything. --- .../payload/storage/ExternalStorage.java | 20 +++++++++---------- .../payload/storage/ExternalStorageTest.java | 7 +++---- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java index d99f3fcca5..cc0bc0a9b9 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java @@ -22,12 +22,12 @@ public static Builder newBuilder() { private final @Nonnull List drivers; private final @Nonnull StorageDriverSelector driverSelector; - private final @Nullable Integer payloadSizeThreshold; + private final int payloadSizeThreshold; private ExternalStorage( @Nonnull List drivers, @Nonnull StorageDriverSelector driverSelector, - @Nullable Integer payloadSizeThreshold) { + int payloadSizeThreshold) { this.drivers = Collections.unmodifiableList(new ArrayList<>(drivers)); this.driverSelector = driverSelector; this.payloadSizeThreshold = payloadSizeThreshold; @@ -44,18 +44,17 @@ public StorageDriverSelector getDriverSelector() { } /** - * Minimum payload size in bytes before external storage is considered. - * {@code null} stores all. Defaults to 256 KiB. + * Minimum payload size in bytes before external storage is considered. {@code 0} stores all + * payloads. Defaults to 256 KiB. */ - @Nullable - public Integer getPayloadSizeThreshold() { + public int getPayloadSizeThreshold() { return payloadSizeThreshold; } public static final class Builder { private List drivers = Collections.emptyList(); private StorageDriverSelector driverSelector; - private Integer payloadSizeThreshold = DEFAULT_PAYLOAD_SIZE_THRESHOLD; + private int payloadSizeThreshold = DEFAULT_PAYLOAD_SIZE_THRESHOLD; private Builder() {} @@ -70,8 +69,8 @@ public Builder setDriverSelector(@Nullable StorageDriverSelector driverSelector) return this; } - /** Set to {@code null} to store all payloads. Defaults to 256 KiB. */ - public Builder setPayloadSizeThreshold(@Nullable Integer payloadSizeThreshold) { + /** Set to {@code 0} to store all payloads. Defaults to 256 KiB. */ + public Builder setPayloadSizeThreshold(int payloadSizeThreshold) { this.payloadSizeThreshold = payloadSizeThreshold; return this; } @@ -79,8 +78,7 @@ public Builder setPayloadSizeThreshold(@Nullable Integer payloadSizeThreshold) { public ExternalStorage build() { Preconditions.checkArgument(!drivers.isEmpty(), "At least one driver must be provided"); Preconditions.checkArgument( - payloadSizeThreshold == null || payloadSizeThreshold >= 0, - "payloadSizeThreshold must be greater than or equal to zero"); + payloadSizeThreshold >= 0, "payloadSizeThreshold must be greater than or equal to zero"); Set names = new HashSet<>(); for (StorageDriver driver : drivers) { String name = driver.getName(); diff --git a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java index 7b1e085bc0..7034dc3202 100644 --- a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java +++ b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java @@ -2,7 +2,6 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import io.temporal.api.common.v1.Payload; @@ -61,13 +60,13 @@ public void multipleDriversWithSelectorIsValid() { } @Test - public void nullThresholdStoresAll() { + public void zeroThresholdStoresAll() { ExternalStorage storage = ExternalStorage.newBuilder() .setDrivers(Collections.singletonList(driver("a"))) - .setPayloadSizeThreshold(null) + .setPayloadSizeThreshold(0) .build(); - assertNull(storage.getPayloadSizeThreshold()); + assertEquals(0, storage.getPayloadSizeThreshold()); } @Test(expected = IllegalArgumentException.class) From 660ac6bef39b860e83114fddaa64e146bacdd11a Mon Sep 17 00:00:00 2001 From: Chris Constable Date: Thu, 4 Jun 2026 16:20:28 -0400 Subject: [PATCH 4/8] Update extstore builder to use checkState instead of checkArguments. --- .../java/io/temporal/payload/storage/ExternalStorage.java | 8 ++++---- .../io/temporal/payload/storage/ExternalStorageTest.java | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java index cc0bc0a9b9..728b263723 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java @@ -76,16 +76,16 @@ public Builder setPayloadSizeThreshold(int payloadSizeThreshold) { } public ExternalStorage build() { - Preconditions.checkArgument(!drivers.isEmpty(), "At least one driver must be provided"); - Preconditions.checkArgument( + Preconditions.checkState(!drivers.isEmpty(), "At least one driver must be provided"); + Preconditions.checkState( payloadSizeThreshold >= 0, "payloadSizeThreshold must be greater than or equal to zero"); Set names = new HashSet<>(); for (StorageDriver driver : drivers) { String name = driver.getName(); - Preconditions.checkArgument( + Preconditions.checkState( names.add(name), "Multiple drivers registered with name '%s'", name); } - Preconditions.checkArgument( + Preconditions.checkState( drivers.size() == 1 || driverSelector != null, "driverSelector must be specified when more than one driver is registered"); StorageDriverSelector selector = driverSelector; diff --git a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java index 7034dc3202..c73504da5d 100644 --- a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java +++ b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java @@ -69,22 +69,22 @@ public void zeroThresholdStoresAll() { assertEquals(0, storage.getPayloadSizeThreshold()); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = IllegalStateException.class) public void noDriversRejected() { ExternalStorage.newBuilder().build(); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = IllegalStateException.class) public void duplicateDriverNamesRejected() { ExternalStorage.newBuilder().setDrivers(Arrays.asList(driver("dup"), driver("dup"))).build(); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = IllegalStateException.class) public void multipleDriversRequireSelector() { ExternalStorage.newBuilder().setDrivers(Arrays.asList(driver("a"), driver("b"))).build(); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = IllegalStateException.class) public void negativeThresholdRejected() { ExternalStorage.newBuilder() .setDrivers(Collections.singletonList(driver("a"))) From 210926155282123d0906fa82c6f767288b6de5ad Mon Sep 17 00:00:00 2001 From: Chris Constable Date: Thu, 4 Jun 2026 16:21:31 -0400 Subject: [PATCH 5/8] Require extstore drivers to give an explicit type. Relying on defaults could be unstable. --- .../java/io/temporal/payload/storage/ExternalStorage.java | 1 + .../java/io/temporal/payload/storage/StorageDriver.java | 7 +++---- .../io/temporal/payload/storage/ExternalStorageTest.java | 5 +++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java index 728b263723..4fd28e0869 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java @@ -64,6 +64,7 @@ public Builder setDrivers(@Nonnull List drivers) { return this; } + /** Required when more than one driver is registered; with a single driver it is optional. */ public Builder setDriverSelector(@Nullable StorageDriverSelector driverSelector) { this.driverSelector = driverSelector; return this; diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java index dda0ec567b..b970722e42 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java @@ -20,12 +20,11 @@ public interface StorageDriver { /** * Stable, implementation-level identifier for this driver, the same across all instances of the * driver class and ideally across SDKs (e.g. {@code "aws.s3driver"}). Used for metrics and worker - * heartbeat reporting. + * heartbeat reporting, so it must not be derived from anything that changes between versions or + * refactors. */ @Nonnull - default String getType() { - return getClass().getName(); - } + String getType(); /** * Stores {@code payloads} and returns one {@link StorageDriverClaim} per payload, in the same diff --git a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java index c73504da5d..82cd02b3f8 100644 --- a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java +++ b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java @@ -20,6 +20,11 @@ public String getName() { return name; } + @Override + public String getType() { + return "test"; + } + @Override public CompletableFuture> store( StorageDriverStoreContext context, List payloads) { From 7eae7a1ce4b8e298e4338aae1689da4ca706b30c Mon Sep 17 00:00:00 2001 From: Chris Constable Date: Thu, 4 Jun 2026 16:22:05 -0400 Subject: [PATCH 6/8] Add equals and hash conformance for extstore types. --- .../storage/StorageDriverActivityInfo.java | 20 +++++++++++++++++++ .../payload/storage/StorageDriverClaim.java | 16 +++++++++++++++ .../storage/StorageDriverWorkflowInfo.java | 20 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java index fd0dc92a04..f660cccfe2 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java @@ -47,4 +47,24 @@ public String getRunId() { public String getType() { return type; } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof StorageDriverActivityInfo)) { + return false; + } + StorageDriverActivityInfo that = (StorageDriverActivityInfo) o; + return namespace.equals(that.namespace) + && Objects.equals(id, that.id) + && Objects.equals(runId, that.runId) + && Objects.equals(type, that.type); + } + + @Override + public int hashCode() { + return Objects.hash(namespace, id, runId, type); + } } diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverClaim.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverClaim.java index 64996375e6..21a9cc6f9f 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverClaim.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverClaim.java @@ -25,4 +25,20 @@ public StorageDriverClaim(@Nonnull Map claimData) { public Map getClaimData() { return claimData; } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof StorageDriverClaim)) { + return false; + } + return claimData.equals(((StorageDriverClaim) o).claimData); + } + + @Override + public int hashCode() { + return claimData.hashCode(); + } } diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java index a13225f103..c36cfe710b 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java @@ -47,4 +47,24 @@ public String getRunId() { public String getType() { return type; } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof StorageDriverWorkflowInfo)) { + return false; + } + StorageDriverWorkflowInfo that = (StorageDriverWorkflowInfo) o; + return namespace.equals(that.namespace) + && Objects.equals(id, that.id) + && Objects.equals(runId, that.runId) + && Objects.equals(type, that.type); + } + + @Override + public int hashCode() { + return Objects.hash(namespace, id, runId, type); + } } From 96ec5a619e107344fc495ee15a54d9185bc88a03 Mon Sep 17 00:00:00 2001 From: Chris Constable Date: Thu, 4 Jun 2026 16:22:53 -0400 Subject: [PATCH 7/8] Add a convenience overload for setDrivers for varargs. --- .../java/io/temporal/payload/storage/ExternalStorage.java | 5 +++++ .../io/temporal/payload/storage/ExternalStorageTest.java | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java index 4fd28e0869..7ed7b07088 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java @@ -64,6 +64,11 @@ public Builder setDrivers(@Nonnull List drivers) { return this; } + /** Convenience for registering a single driver; no selector is needed in this case. */ + public Builder setDriver(@Nonnull StorageDriver driver) { + return setDrivers(Collections.singletonList(Objects.requireNonNull(driver, "driver"))); + } + /** Required when more than one driver is registered; with a single driver it is optional. */ public Builder setDriverSelector(@Nullable StorageDriverSelector driverSelector) { this.driverSelector = driverSelector; diff --git a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java index 82cd02b3f8..efe81f6427 100644 --- a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java +++ b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java @@ -42,8 +42,7 @@ public CompletableFuture> retrieve( @Test public void singleDriverNoSelectorSynthesizesSelector() { StorageDriver a = driver("a"); - ExternalStorage storage = - ExternalStorage.newBuilder().setDrivers(Collections.singletonList(a)).build(); + ExternalStorage storage = ExternalStorage.newBuilder().setDriver(a).build(); assertEquals(1, storage.getDrivers().size()); StorageDriverSelector selector = storage.getDriverSelector(); assertNotNull(selector); From 1ee8e92213b40167a67df97d45554124f2ad6596 Mon Sep 17 00:00:00 2001 From: Chris Constable Date: Thu, 4 Jun 2026 16:33:35 -0400 Subject: [PATCH 8/8] Add comments to storage driver info classes. --- .../temporal/payload/storage/StorageDriverActivityInfo.java | 6 ++++++ .../temporal/payload/storage/StorageDriverWorkflowInfo.java | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java index f660cccfe2..52b2d9e0d5 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java @@ -17,6 +17,12 @@ public final class StorageDriverActivityInfo implements StorageDriverTargetInfo private final @Nullable String runId; private final @Nullable String type; + /** + * @param namespace the activity's namespace; must not be {@code null} + * @param id the activity ID, or {@code null} if not available + * @param runId the activity run ID (standalone activities), or {@code null} if not available + * @param type the activity type name, or {@code null} if not available + */ public StorageDriverActivityInfo( @Nonnull String namespace, @Nullable String id, diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java index c36cfe710b..455025a8b0 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java @@ -17,6 +17,12 @@ public final class StorageDriverWorkflowInfo implements StorageDriverTargetInfo private final @Nullable String runId; private final @Nullable String type; + /** + * @param namespace the workflow's namespace; must not be {@code null} + * @param id the workflow ID, or {@code null} if not available + * @param runId the workflow run ID, or {@code null} if not available + * @param type the workflow type name, or {@code null} if not available + */ public StorageDriverWorkflowInfo( @Nonnull String namespace, @Nullable String id,