Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept this private to be consistent with TS. However, I had to remove DEFAULT_PAYLOAD_SIZE_THRESHOLD links from the doc comments so they would actually resolved (and not be broken). Seems reasonable to let users knows what the default is in the doc comments so I added a "Defaults to 256 KiB" but this feels a little worse to me than just making this public. Looking for feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make this package-private (so just remove the private keyword) and then generate the docs via javadoc with -package? Haven't tried it myself and not sure what else might become docuemented.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think promoting to package is reasonable. One consideration for making it public is being able to reference it in generated docs e.g. on the setThreshold method "The default value is {@link ExternalStorage.DEFAULT_PAYLOAD_SIZE_THRESHOLD" instead of what I have now: "Defaults to 256 KiB".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can use @value to embed the actual value. I still don't see the value that having an effective public constant has for customers. Sure, it's great they know what the default threshold is, but they aren't going to write code specifically with that in mind. So we shouldn't provide it in code. Documentation, absolutely.


public static Builder newBuilder() {
return new Builder();
}

private final @Nonnull List<StorageDriver> drivers;
private final @Nonnull StorageDriverSelector driverSelector;
private final int payloadSizeThreshold;

private ExternalStorage(
@Nonnull List<StorageDriver> drivers,
@Nonnull StorageDriverSelector driverSelector,
int payloadSizeThreshold) {
this.drivers = Collections.unmodifiableList(new ArrayList<>(drivers));
this.driverSelector = driverSelector;
this.payloadSizeThreshold = payloadSizeThreshold;
}

@Nonnull
public List<StorageDriver> getDrivers() {
return drivers;
}

@Nonnull
public StorageDriverSelector getDriverSelector() {
return driverSelector;
}

/**
* Minimum payload size in bytes before external storage is considered. {@code 0} stores all
* payloads. Defaults to 256 KiB.
*/
public int getPayloadSizeThreshold() {
return payloadSizeThreshold;
}

public static final class Builder {
private List<StorageDriver> drivers = Collections.emptyList();
private StorageDriverSelector driverSelector;
private int 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<StorageDriver> drivers) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we document that when calling setDrivers or setDriver that the last call wins? Not sure if that is clear, especially in the case of calling setDriver multiple times.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good builder pattern call out 😄 I'll update.

this.drivers = Objects.requireNonNull(drivers, "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;
return this;
}

/** Set to {@code 0} to store all payloads. Defaults to 256 KiB. */
public Builder setPayloadSizeThreshold(int payloadSizeThreshold) {
this.payloadSizeThreshold = payloadSizeThreshold;
return this;
}

public ExternalStorage build() {
Copy link
Copy Markdown
Author

@cconstable cconstable Jun 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now, these all throw state exceptions (which are tested). In a subsequent PR, I would like to replace these with stable diagnostic codes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For these in particular, do we need diagnostic codes? The messages are fairly clear as to what the issue is and prevent the use of external storage at startup rather than having to report during execution runtime.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a proponent of best-effort, stable diagnostic codes if the mechanism for adding them very easy. I have a little write-up about this that I wanted to share but the tl;dr is I think we should make a best effort to tag anything that's actionable if for nothing else it supports automated tooling (e.g. AI) and feels like a healthy industry trend (hand wavy gesture). I would not have felt this way 4 years ago.

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<String> names = new HashSet<>();
for (StorageDriver driver : drivers) {
String name = driver.getName();
Preconditions.checkState(
names.add(name), "Multiple drivers registered with name '%s'", name);
}
Preconditions.checkState(
drivers.size() == 1 || driverSelector != null,
"driverSelector must be specified when more than one driver is registered");
StorageDriverSelector selector = driverSelector;
if (selector == null) {
StorageDriver driver = drivers.get(0);
selector = (context, payload) -> driver;
}
return new ExternalStorage(drivers, selector, payloadSizeThreshold);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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, so it must not be derived from anything that changes between versions or
* refactors.
*/
@Nonnull
String getType();

/**
* 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<List<StorageDriverClaim>> store(
@Nonnull StorageDriverStoreContext context, @Nonnull List<Payload> 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<List<Payload>> retrieve(
@Nonnull StorageDriverRetrieveContext context, @Nonnull List<StorageDriverClaim> claims);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package io.temporal.payload.storage;

import io.temporal.common.Experimental;
import java.util.Objects;
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;
private final @Nullable String id;
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,
@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;
}

@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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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<String, String> claimData;

public StorageDriverClaim(@Nonnull Map<String, String> claimData) {
this.claimData =
Collections.unmodifiableMap(new HashMap<>(Objects.requireNonNull(claimData, "claimData")));
}

@Nonnull
public Map<String, String> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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 {}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about if making this and StorageDriverStoreContext interfaces instead of finalized classes. Might make it easier for us internally define the implementations and thus refactor/update them as necessary. In either case, there is burden on driver authors who write unit tests for their drivers to have to adapt to either changing APIs on classes or interfaces, but interfaces would allow them to implement however they deem necessary to fulfill the context contract.

Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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 {}
Loading
Loading