diff --git a/temporal-sdk/src/payloadVisitorGenerator/java/io/temporal/internal/payload/visitor/gen/PayloadVisitorGenerator.java b/temporal-sdk/src/payloadVisitorGenerator/java/io/temporal/internal/payload/visitor/gen/PayloadVisitorGenerator.java index ad2924a51e..c2285f620e 100644 --- a/temporal-sdk/src/payloadVisitorGenerator/java/io/temporal/internal/payload/visitor/gen/PayloadVisitorGenerator.java +++ b/temporal-sdk/src/payloadVisitorGenerator/java/io/temporal/internal/payload/visitor/gen/PayloadVisitorGenerator.java @@ -163,6 +163,8 @@ static boolean isTemporal(Descriptor d) { } // --- Java naming --- + // These helpers are duplicated in temporal-serviceclient as + // io.temporal.internal.payload.limits.gen.ProtoNames; see ProtoClosure for why. /** Mirrors protoc's UnderscoresToCamelCase used to derive Java accessor names. */ static String camel(String input, boolean capNext) { diff --git a/temporal-sdk/src/payloadVisitorGenerator/java/io/temporal/internal/payload/visitor/gen/ProtoClosure.java b/temporal-sdk/src/payloadVisitorGenerator/java/io/temporal/internal/payload/visitor/gen/ProtoClosure.java index 9772d4b085..12bc7047e6 100644 --- a/temporal-sdk/src/payloadVisitorGenerator/java/io/temporal/internal/payload/visitor/gen/ProtoClosure.java +++ b/temporal-sdk/src/payloadVisitorGenerator/java/io/temporal/internal/payload/visitor/gen/ProtoClosure.java @@ -15,8 +15,22 @@ import java.util.Set; /** - * Shared proto-descriptor model for the build-time generators: the message closure reachable from a + * Proto-descriptor model for {@link PayloadVisitorGenerator}: the message closure reachable from a * set of seed services, and which of those messages can transitively contain a {@code Payload}. + * + *

A near-copy of this class exists in {@code temporal-serviceclient}, as {@code + * io.temporal.internal.payload.limits.gen.ProtoClosure}, serving the payload-limits generator. The + * duplication is deliberate: the two generators live in different modules (the limits validator has + * to sit next to the gRPC interceptor in {@code temporal-serviceclient}, whose protos its own build + * generates), and sharing one copy would need cross-project build wiring this repo has no other + * instance of. The copies differ only in how each obtains its {@code Descriptor}s — compiled proto + * classes here, a descriptor set file there. + * + *

Payload reachability must stay identical in both. A change here that narrows + * reachability would silently make the visitor traverse fewer messages, which is how payloads + * quietly stop being offloaded; the same change there surfaces loudly as an unclassified-field + * build failure. Keep the two in sync, and prefer verifying a change by confirming both generators' + * output is byte-identical before and after it. */ final class ProtoClosure { diff --git a/temporal-serviceclient/build.gradle b/temporal-serviceclient/build.gradle index 1da1e24eee..69c087a818 100644 --- a/temporal-serviceclient/build.gradle +++ b/temporal-serviceclient/build.gradle @@ -75,6 +75,10 @@ sourceSets { } } +// Descriptor set emitted by generateProto and read by the payload-limits generator; see the +// payload limit validation section below. +def apiDescriptorSet = layout.buildDirectory.file('generated/descriptors/temporal-api.desc') + protobuf { // version/variables substitution is not supported in protobuf section. // protoc and protoc-gen-grpc-java versions are selected to be compatible @@ -91,7 +95,16 @@ protobuf { } } generateProtoTasks { - all().each { task -> task.dependsOn updateSubmodules } + all().each { task -> + task.dependsOn updateSubmodules + // The payload-limits generator reads the API descriptors from this file rather than + // from compiled proto classes; see the payload limit validation section below. + if (task.sourceSet.name == 'main') { + task.generateDescriptorSet = true + task.descriptorSetOptions.includeImports = true + task.descriptorSetOptions.path = apiDescriptorSet.get().asFile.absolutePath + } + } all()*.builtins { java { option 'annotate_code' @@ -113,6 +126,48 @@ protobuf { generatedFilesBaseDir = "$buildDir/generated" } +// --- Payload limit validation code generation --- +// A build-time generator emits GeneratedPayloadLimitValidator.java, which checks the payload and +// memo fields of outbound requests against the size limits the server enforces. It classifies every +// payload-bearing field against hand-authored tables and fails the build if a field is unclassified +// or a table entry is stale, so a proto change cannot land without an explicit decision. +// +// The generator reads the API descriptors from the descriptor set emitted by generateProto, not from +// compiled proto classes. The protos are generated into this module's own main source set, so a +// generator that needed them compiled would form a cycle with the compilation that consumes its +// output. Its source set therefore depends only on protobuf-java. +sourceSets { + payloadLimitsGenerator { + java { + srcDirs = ['src/payloadLimitsGenerator/java'] + } + } +} + +dependencies { + payloadLimitsGeneratorImplementation "com.google.protobuf:protobuf-java:$protoVersion" +} + +def generatedPayloadLimitsDir = layout.buildDirectory.dir('generated/payloadlimits/java') + +def generatePayloadLimitValidator = tasks.register('generatePayloadLimitValidator', JavaExec) { + dependsOn 'compilePayloadLimitsGeneratorJava', 'generateProto' + classpath = sourceSets.payloadLimitsGenerator.runtimeClasspath + mainClass = 'io.temporal.internal.payload.limits.gen.PayloadLimitValidatorGenerator' + args apiDescriptorSet.get().asFile.absolutePath, generatedPayloadLimitsDir.get().asFile.absolutePath + inputs.file(apiDescriptorSet) + inputs.files(sourceSets.payloadLimitsGenerator.runtimeClasspath) + outputs.dir(generatedPayloadLimitsDir) +} + +sourceSets.main.java.srcDir(generatePayloadLimitValidator) + +tasks.named('compilePayloadLimitsGeneratorJava') { + options.encoding = 'UTF-8' + options.compilerArgs << '-Xlint:none' << '-Xlint:deprecation' << '-Werror' + options.errorprone.error('MissingCasesInEnumSwitch') +} + javadocJar { dependsOn 'generateProto' from(file("$buildDir/generated/main/java")) diff --git a/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/CollectingSink.java b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/CollectingSink.java new file mode 100644 index 0000000000..e9ae053117 --- /dev/null +++ b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/CollectingSink.java @@ -0,0 +1,65 @@ +package io.temporal.internal.payload.limits; + +import java.util.ArrayList; +import java.util.List; + +/** + * A {@link PayloadLimitSink} that collects violations without logging or policy decisions. + * + *

Each checked field is sorted into {@link #getWarnings()} or {@link #getErrors()}: a field over + * its error threshold (when {@code enforceError} and an error threshold are set) is an error; + * otherwise a field over its warning threshold is a warning. + */ +final class CollectingSink implements PayloadLimitSink { + private final PayloadLimits limits; + private final PayloadPath path = new PayloadPath(); + private final List warnings = new ArrayList<>(); + private final List errors = new ArrayList<>(); + + CollectingSink(PayloadLimits limits) { + this.limits = limits; + } + + @Override + public void check(String fieldName, LimitClass limitClass, long size, boolean enforceError) { + long error = limits.error(limitClass); + long warn = limits.warn(limitClass); + if (enforceError && error > 0 && size > error) { + errors.add( + new PayloadLimitViolation( + path.leaf(fieldName), limitClass, LimitSeverity.ERROR, size, error)); + } else if (warn > 0 && size > warn) { + warnings.add( + new PayloadLimitViolation( + path.leaf(fieldName), limitClass, LimitSeverity.WARNING, size, warn)); + } + } + + @Override + public void enter(String name) { + path.push(name); + } + + @Override + public void enter(String name, int index) { + path.push(name, index); + } + + @Override + public void enter(String name, String key) { + path.push(name, key); + } + + @Override + public void exit() { + path.pop(); + } + + List getWarnings() { + return warnings; + } + + List getErrors() { + return errors; + } +} diff --git a/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/LimitClass.java b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/LimitClass.java new file mode 100644 index 0000000000..515b10b2ad --- /dev/null +++ b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/LimitClass.java @@ -0,0 +1,9 @@ +package io.temporal.internal.payload.limits; + +/** Which server-enforced size limit a payload field is subject to. */ +enum LimitClass { + /** Subject to the blob (payload) size limit. */ + BLOB, + /** Subject to the memo size limit. */ + MEMO +} diff --git a/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/LimitSeverity.java b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/LimitSeverity.java new file mode 100644 index 0000000000..f97eb421f1 --- /dev/null +++ b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/LimitSeverity.java @@ -0,0 +1,9 @@ +package io.temporal.internal.payload.limits; + +/** Whether a violation exceeded the warning threshold or the error threshold. */ +enum LimitSeverity { + /** Exceeded the warning threshold. */ + WARNING, + /** Exceeded the error threshold. */ + ERROR +} diff --git a/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadLimitSink.java b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadLimitSink.java new file mode 100644 index 0000000000..e6a581ee8f --- /dev/null +++ b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadLimitSink.java @@ -0,0 +1,33 @@ +package io.temporal.internal.payload.limits; + +/** + * Receives one callback per validated payload field, with the field's size as the server measures + * it. Implementors decide how to handle warnings and errors. + * + *

The generated traversal ({@code GeneratedPayloadLimitValidator}) calls {@link #enter}/{@link + * #exit} around each nested message so the sink can track a field's location for {@link #check}. + */ +interface PayloadLimitSink { + /** + * Called for each validated payload field. + * + * @param fieldName the leaf field's proto name + * @param limitClass which limit the field is subject to + * @param size the field's size in bytes for the given class + * @param enforceError when {@code false}, the field may warn but must never produce an + * error-level violation + */ + void check(String fieldName, LimitClass limitClass, long size, boolean enforceError); + + /** Enter a singular nested-message field with proto name {@code name}. */ + void enter(String name); + + /** Enter element {@code index} of a repeated nested-message field {@code name}. */ + void enter(String name, int index); + + /** Enter the entry under {@code key} of a map-valued nested-message field {@code name}. */ + void enter(String name, String key); + + /** Leave the most recently entered nested field. */ + void exit(); +} diff --git a/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadLimitSizes.java b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadLimitSizes.java new file mode 100644 index 0000000000..d9a1bac041 --- /dev/null +++ b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadLimitSizes.java @@ -0,0 +1,67 @@ +package io.temporal.internal.payload.limits; + +import com.google.protobuf.MessageLite; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Map; + +/** + * Field-size measurements, mirroring how the Temporal server measures each field. Size is the + * serialized proto size ({@link MessageLite#getSerializedSize()}) except for the map-aggregate + * helpers, which mirror the server's {@code sum(len(key) + ...)} accounting. Called from the + * generated {@code GeneratedPayloadLimitValidator}. + */ +final class PayloadLimitSizes { + private PayloadLimitSizes() {} + + /** + * Serialized proto size of a single message (e.g. Payload, Payloads, Memo, or a whole Failure). + */ + static long serializedSize(MessageLite message) { + return message.getSerializedSize(); + } + + /** + * Sum of serialized proto sizes over a collection of messages (e.g. repeated Payload/Failure). + */ + static long serializedSizeSum(Collection messages) { + long total = 0; + for (MessageLite m : messages) { + total += m.getSerializedSize(); + } + return total; + } + + /** + * Aggregate size of a marker-style {@code map}, mirroring the server's {@code + * sum(len(key) + payloads.Size())} accounting (e.g. {@code + * RecordMarkerCommandAttributes.details}). + */ + static long mapPayloadsSum(Map entries) { + long total = 0; + for (Map.Entry e : entries.entrySet()) { + total += utf8Length(e.getKey()) + e.getValue().getSerializedSize(); + } + return total; + } + + /** + * Aggregate size of a search-attribute/memo-style {@code map}, mirroring the + * server's {@code sum(len(key) + len(payload.data))} accounting — note the server counts the + * raw data length here, not the serialized payload size (e.g. {@code + * UpsertWorkflowSearchAttributes.indexed_fields}). + */ + static long mapPayloadDataSum(Map entries) { + long total = 0; + for (Map.Entry e : entries.entrySet()) { + total += utf8Length(e.getKey()) + e.getValue().getData().size(); + } + return total; + } + + private static int utf8Length(String s) { + return s.getBytes(StandardCharsets.UTF_8).length; + } +} diff --git a/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadLimitValidator.java b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadLimitValidator.java new file mode 100644 index 0000000000..61b7e64a63 --- /dev/null +++ b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadLimitValidator.java @@ -0,0 +1,56 @@ +package io.temporal.internal.payload.limits; + +import com.google.protobuf.Message; +import java.util.List; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Validates an outbound request message's payload/memo fields against a set of {@link + * PayloadLimits}, mirroring the size checks the Temporal server enforces. + * + *

The per-(message, field) policy is generated from the proto descriptors against the + * hand-authored {@code *_FIELDS} tables in {@code PayloadLimitValidatorGenerator}; adding or + * removing a payload-bearing field fails the build until the tables are updated. + */ +final class PayloadLimitValidator { + private static final Logger log = LoggerFactory.getLogger(PayloadLimitValidator.class); + + private PayloadLimitValidator() {} + + /** + * Validates {@code request} against {@code limits}. + * + *

If any field exceeded its error threshold, logs the error(s) and returns the first one + * without logging warnings; otherwise logs each warning and returns empty. With no error + * thresholds set (or a request type that carries no validated payload fields), this only warns + * and always returns empty. + */ + static Optional validate(Message request, PayloadLimits limits) { + CollectingSink sink = new CollectingSink(limits); + GeneratedPayloadLimitValidator.dispatch(sink, request); + + List errors = sink.getErrors(); + if (!errors.isEmpty()) { + for (PayloadLimitViolation e : errors) { + log.error( + "{} (size={}, limit={}, path={})", + e.getMessage(), + e.getSize(), + e.getLimit(), + e.getPath()); + } + return Optional.of(errors.get(0)); + } + for (PayloadLimitViolation w : sink.getWarnings()) { + log.warn( + "{} (size={}, limit={}, path={})", + w.getMessage(), + w.getSize(), + w.getLimit(), + w.getPath()); + } + return Optional.empty(); + } +} diff --git a/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadLimitViolation.java b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadLimitViolation.java new file mode 100644 index 0000000000..6c8ddf6728 --- /dev/null +++ b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadLimitViolation.java @@ -0,0 +1,63 @@ +package io.temporal.internal.payload.limits; + +/** A payload field whose size exceeded one of its configured thresholds (warning or error). */ +final class PayloadLimitViolation { + private final String path; + private final LimitClass limitClass; + private final LimitSeverity severity; + private final long size; + private final long limit; + + PayloadLimitViolation( + String path, LimitClass limitClass, LimitSeverity severity, long size, long limit) { + this.path = path; + this.limitClass = limitClass; + this.severity = severity; + this.size = size; + this.limit = limit; + } + + /** + * Path of proto field names from the root message (e.g. {@code + * commands[2].schedule_activity_task_command_attributes.input}). + */ + String getPath() { + return path; + } + + LimitClass getLimitClass() { + return limitClass; + } + + LimitSeverity getSeverity() { + return severity; + } + + /** The field's measured size in bytes. */ + long getSize() { + return size; + } + + /** + * The threshold that was exceeded (warning threshold for warnings, error threshold for errors). + */ + long getLimit() { + return limit; + } + + /** The user-facing {@code [TMPRL1103]} message. */ + String getMessage() { + String limitClassName = limitClass == LimitClass.BLOB ? "payloads" : "memo"; + String limitKind = severity == LimitSeverity.WARNING ? "warning" : "error"; + return "[TMPRL1103] Attempted to upload " + + limitClassName + + " with size that exceeded the " + + limitKind + + " limit."; + } + + @Override + public String toString() { + return getMessage(); + } +} diff --git a/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadLimits.java b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadLimits.java new file mode 100644 index 0000000000..7dd01dc19e --- /dev/null +++ b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadLimits.java @@ -0,0 +1,35 @@ +package io.temporal.internal.payload.limits; + +/** + * Warn/error thresholds (bytes) for both limit classes. A {@code 0} threshold disables that check + * for that class: {@code 0} warn = no warnings, {@code 0} error = no error enforcement (warnings + * only). + */ +final class PayloadLimits { + private final long blobWarn; + private final long blobError; + private final long memoWarn; + private final long memoError; + + PayloadLimits(long blobWarn, long blobError, long memoWarn, long memoError) { + this.blobWarn = blobWarn; + this.blobError = blobError; + this.memoWarn = memoWarn; + this.memoError = memoError; + } + + /** All thresholds disabled ({@code 0}). */ + static PayloadLimits none() { + return new PayloadLimits(0, 0, 0, 0); + } + + /** The warning threshold for {@code clazz}; {@code 0} means disabled. */ + long warn(LimitClass clazz) { + return clazz == LimitClass.BLOB ? blobWarn : memoWarn; + } + + /** The error threshold for {@code clazz}; {@code 0} means disabled. */ + long error(LimitClass clazz) { + return clazz == LimitClass.BLOB ? blobError : memoError; + } +} diff --git a/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadPath.java b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadPath.java new file mode 100644 index 0000000000..ecfb419923 --- /dev/null +++ b/temporal-serviceclient/src/main/java/io/temporal/internal/payload/limits/PayloadPath.java @@ -0,0 +1,65 @@ +package io.temporal.internal.payload.limits; + +import java.util.ArrayList; +import java.util.List; + +/** + * Path of proto field names to the field being validated; proto names keep it language-agnostic. A + * helper sinks embed to track location across {@link PayloadLimitSink#enter}/{@link + * PayloadLimitSink#exit}. + * + *

Segments are kept unrendered so that entering and leaving a message costs no allocation; the + * path string is built only by {@link #leaf}, which a sink calls only when it has a violation to + * report. The traversal runs on every outbound request, while violations are rare. + */ +final class PayloadPath { + private static final int NO_INDEX = -1; + + private final List names = new ArrayList<>(); + private final List keys = new ArrayList<>(); + private final List indexes = new ArrayList<>(); + + void push(String name) { + push(name, null, NO_INDEX); + } + + void push(String name, int index) { + push(name, null, index); + } + + void push(String name, String key) { + push(name, key, NO_INDEX); + } + + private void push(String name, String key, int index) { + names.add(name); + keys.add(key); + indexes.add(index); + } + + void pop() { + int last = names.size() - 1; + names.remove(last); + keys.remove(last); + indexes.remove(last); + } + + /** The full dotted path to a leaf field with proto name {@code fieldName}. */ + String leaf(String fieldName) { + if (names.isEmpty()) { + return fieldName; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < names.size(); i++) { + sb.append(names.get(i)); + String key = keys.get(i); + if (key != null) { + sb.append('[').append(key).append(']'); + } else if (indexes.get(i) != NO_INDEX) { + sb.append('[').append(indexes.get(i)).append(']'); + } + sb.append('.'); + } + return sb.append(fieldName).toString(); + } +} diff --git a/temporal-serviceclient/src/payloadLimitsGenerator/java/io/temporal/internal/payload/limits/gen/PayloadLimitValidatorGenerator.java b/temporal-serviceclient/src/payloadLimitsGenerator/java/io/temporal/internal/payload/limits/gen/PayloadLimitValidatorGenerator.java new file mode 100644 index 0000000000..60a67295d0 --- /dev/null +++ b/temporal-serviceclient/src/payloadLimitsGenerator/java/io/temporal/internal/payload/limits/gen/PayloadLimitValidatorGenerator.java @@ -0,0 +1,867 @@ +package io.temporal.internal.payload.limits.gen; + +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Descriptors.DescriptorValidationException; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.Descriptors.FileDescriptor; +import com.google.protobuf.Descriptors.MethodDescriptor; +import com.google.protobuf.Descriptors.ServiceDescriptor; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * Build-time generator that emits {@code GeneratedPayloadLimitValidator}, the payload/memo + * size-limit validator mirroring the Temporal server's size checks. + * + *

Starting from the request (RPC input) messages of {@code WorkflowService} and {@code + * OperatorService}, it walks the proto closure, stopping at terminal payload/memo leaf types, and + * emits one {@code visit_*} method per payload-bearing message plus an {@code instanceof} + * dispatcher over the request roots. Each leaf is classified against the hand-authored {@code + * *_FIELDS} tables below; the per-field measurement is derived mechanically from the proto shape. + * + *

The tables are the compile-time forcing function: a payload/memo-bearing field that is missing + * from every table (or a stale/duplicate table entry) fails the build, so a proto change + * cannot land until it is explicitly classified here. + * + *

Descriptors are read from a protoc-emitted descriptor set file rather than from compiled proto + * classes, so that this generator does not depend on the compiled output of the module it generates + * into. + * + *

Usage: {@code PayloadLimitValidatorGenerator }. + */ +public final class PayloadLimitValidatorGenerator { + + static final String PAYLOAD = "temporal.api.common.v1.Payload"; + static final String PAYLOADS = "temporal.api.common.v1.Payloads"; + static final String MEMO = "temporal.api.common.v1.Memo"; + static final String HEADER = "temporal.api.common.v1.Header"; + static final String SEARCH_ATTRIBUTES = "temporal.api.common.v1.SearchAttributes"; + static final String FAILURE = "temporal.api.failure.v1.Failure"; + + static final String OUTPUT_PACKAGE = "io.temporal.internal.payload.limits"; + static final String OUTPUT_CLASS = "GeneratedPayloadLimitValidator"; + + /** + * Types the walk stops at: it emits a table-driven leaf check at the holding field rather than + * descending into their inner payload fields. {@code Failure} is measured as a whole proto + * because that is how the server size-checks it (e.g. FailWorkflowExecution). + */ + static final Set TERMINAL_LEAVES = + new HashSet<>(Arrays.asList(PAYLOAD, PAYLOADS, MEMO, HEADER, SEARCH_ATTRIBUTES, FAILURE)); + + /** + * Field paths the server size-checks as a whole serialized sub-message even though the field is + * not itself payload-bearing (so payload reachability never reaches them). Measured via whole- + * message size, classified via the table like any other leaf; the owning message is forced into + * the closure so parents recurse into it. + */ + static final String[] EXTRA_WHOLE_MESSAGE_LEAVES = { + // protocol Message body (google.protobuf.Any): the server blob-checks proto.Size(message.Body) + // when processing update messages and fails the WFT on exceed. + "temporal.api.protocol.v1.Message.body", + }; + + // =========================================================================== + // Payload-limits decision tables — the source of truth for how the SDK mirrors the server's + // payload/memo size checks. Fields are grouped by policy: + // BLOB_FIELDS / MEMO_FIELDS blob / memo limit, warn + error + // BLOB_WARN_FIELDS blob limit, warning only (enforceError = false) + // NOT_VALIDATED_FIELDS the server enforces no replicable limit on the field + // Roots are derived automatically from the seed services' RPC input messages, so a new RPC can't + // be silently missed — its payload fields become unclassified and fail the build until added + // here. + // =========================================================================== + + static final String[] BLOB_FIELDS = { + "temporal.api.command.v1.CompleteWorkflowExecutionCommandAttributes.result", + "temporal.api.command.v1.ContinueAsNewWorkflowExecutionCommandAttributes.input", + "temporal.api.command.v1.FailWorkflowExecutionCommandAttributes.failure", // whole Failure proto + "temporal.api.command.v1.ModifyWorkflowPropertiesCommandAttributes.upserted_memo", // memo + // data-sum + "temporal.api.command.v1.RecordMarkerCommandAttributes.details", // map sum + "temporal.api.command.v1.ScheduleActivityTaskCommandAttributes.input", + "temporal.api.command.v1.ScheduleNexusOperationCommandAttributes.input", + "temporal.api.command.v1.SignalExternalWorkflowExecutionCommandAttributes.input", + "temporal.api.command.v1.StartChildWorkflowExecutionCommandAttributes.input", + "temporal.api.command.v1.UpsertWorkflowSearchAttributesCommandAttributes.search_attributes", // indexed_fields data-sum + "temporal.api.protocol.v1.Message.body", // whole Any body; see EXTRA_WHOLE_MESSAGE_LEAVES + "temporal.api.query.v1.WorkflowQuery.query_args", + "temporal.api.workflow.v1.NewWorkflowExecutionInfo.input", + "temporal.api.workflowservice.v1.RecordActivityTaskHeartbeatByIdRequest.details", + "temporal.api.workflowservice.v1.RecordActivityTaskHeartbeatRequest.details", + "temporal.api.workflowservice.v1.RespondActivityTaskCanceledByIdRequest.details", + "temporal.api.workflowservice.v1.RespondActivityTaskCanceledRequest.details", + "temporal.api.workflowservice.v1.RespondActivityTaskCompletedByIdRequest.result", + "temporal.api.workflowservice.v1.RespondActivityTaskCompletedRequest.result", + "temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionRequest.input", + "temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionRequest.signal_input", + "temporal.api.workflowservice.v1.SignalWorkflowExecutionRequest.input", + "temporal.api.workflowservice.v1.StartActivityExecutionRequest.input", + "temporal.api.workflowservice.v1.StartNexusOperationExecutionRequest.input", + "temporal.api.workflowservice.v1.StartWorkflowExecutionRequest.input", + }; + + static final String[] MEMO_FIELDS = { + "temporal.api.command.v1.ContinueAsNewWorkflowExecutionCommandAttributes.memo", + "temporal.api.command.v1.StartChildWorkflowExecutionCommandAttributes.memo", + "temporal.api.workflow.v1.NewWorkflowExecutionInfo.memo", + "temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionRequest.memo", + "temporal.api.workflowservice.v1.StartWorkflowExecutionRequest.memo", + }; + + // Warn-only: the SDK warns but never proactively fails the task (failure responses; query + // results). + static final String[] BLOB_WARN_FIELDS = { + "temporal.api.workflowservice.v1.RespondActivityTaskFailedByIdRequest.failure", + "temporal.api.workflowservice.v1.RespondActivityTaskFailedByIdRequest.last_heartbeat_details", + "temporal.api.workflowservice.v1.RespondActivityTaskFailedRequest.failure", + "temporal.api.workflowservice.v1.RespondActivityTaskFailedRequest.last_heartbeat_details", + "temporal.api.workflowservice.v1.RespondNexusTaskFailedRequest.failure", + "temporal.api.workflowservice.v1.RespondWorkflowTaskFailedRequest.failure", + "temporal.api.query.v1.WorkflowQueryResult.answer", + "temporal.api.workflowservice.v1.RespondQueryTaskCompletedRequest.query_result", + }; + + static final String[] NOT_VALIDATED_FIELDS = { + // Headers: server records a HeaderSize metric only. + "temporal.api.batch.v1.BatchOperationSignal.header", + "temporal.api.command.v1.ContinueAsNewWorkflowExecutionCommandAttributes.header", + "temporal.api.command.v1.RecordMarkerCommandAttributes.header", + "temporal.api.command.v1.ScheduleActivityTaskCommandAttributes.header", + "temporal.api.command.v1.SignalExternalWorkflowExecutionCommandAttributes.header", + "temporal.api.command.v1.StartChildWorkflowExecutionCommandAttributes.header", + "temporal.api.query.v1.WorkflowQuery.header", + "temporal.api.update.v1.Input.header", + "temporal.api.workflow.v1.NewWorkflowExecutionInfo.header", + "temporal.api.workflow.v1.PostResetOperation.SignalWorkflow.header", + "temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionRequest.header", + "temporal.api.workflowservice.v1.SignalWorkflowExecutionRequest.header", + "temporal.api.workflowservice.v1.StartActivityExecutionRequest.header", + "temporal.api.workflowservice.v1.StartWorkflowExecutionRequest.header", + // Search attributes: separate non-replicable SA limit (server merges with existing SAs). + "temporal.api.command.v1.ContinueAsNewWorkflowExecutionCommandAttributes.search_attributes", + "temporal.api.command.v1.StartChildWorkflowExecutionCommandAttributes.search_attributes", + "temporal.api.workflow.v1.NewWorkflowExecutionInfo.search_attributes", + "temporal.api.workflowservice.v1.CreateScheduleRequest.search_attributes", + "temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionRequest.search_attributes", + "temporal.api.workflowservice.v1.StartActivityExecutionRequest.search_attributes", + "temporal.api.workflowservice.v1.StartNexusOperationExecutionRequest.search_attributes", + "temporal.api.workflowservice.v1.StartWorkflowExecutionRequest.search_attributes", + "temporal.api.workflowservice.v1.UpdateScheduleRequest.search_attributes", + // Internal carry-over fields the SDK doesn't author / the server doesn't size-check here. + "temporal.api.command.v1.CancelWorkflowExecutionCommandAttributes.details", + "temporal.api.command.v1.ContinueAsNewWorkflowExecutionCommandAttributes.failure", + "temporal.api.command.v1.ContinueAsNewWorkflowExecutionCommandAttributes.last_completion_result", + "temporal.api.command.v1.RecordMarkerCommandAttributes.failure", + "temporal.api.workflowservice.v1.StartWorkflowExecutionRequest.continued_failure", + "temporal.api.workflowservice.v1.StartWorkflowExecutionRequest.last_completion_result", + "temporal.api.workflowservice.v1.TerminateWorkflowExecutionRequest.details", + // Dedicated, non-fetchable limits: UserMetadata (nexus-start only); Nexus EndpointSpec + // description. + "temporal.api.sdk.v1.UserMetadata.details", + "temporal.api.sdk.v1.UserMetadata.summary", + "temporal.api.nexus.v1.EndpointSpec.description", + // Event group marker label: custom 400-byte server-side limit, not blob/memo. + "temporal.api.sdk.v1.EventGroupMarker.Label.label", + // Update input args: frontend records a metric only — enforced on delivery via Message.body. + "temporal.api.update.v1.Input.args", + // Query/nexus failures and the nexus sync response payload: not size-checked on these paths. + "temporal.api.nexus.v1.StartOperationResponse.Sync.payload", + "temporal.api.nexus.v1.StartOperationResponse.failure", + "temporal.api.query.v1.WorkflowQueryResult.failure", + "temporal.api.workflowservice.v1.RespondQueryTaskCompletedRequest.failure", + // Schedules: server sums memo + action.input vs blob — cross-field aggregate (deferred). + "temporal.api.workflowservice.v1.CreateScheduleRequest.memo", + "temporal.api.workflowservice.v1.UpdateScheduleRequest.memo", + // Enforced downstream: signal input is blob-checked per target on batch/reset fan-out. + "temporal.api.batch.v1.BatchOperationSignal.input", + "temporal.api.workflow.v1.PostResetOperation.SignalWorkflow.input", + // Not size-checked by the server. + "temporal.api.batch.v1.BatchOperationTermination.details", + "temporal.api.deployment.v1.UpdateDeploymentMetadata.upsert_entries", + "temporal.api.workflowservice.v1.UpdateWorkerDeploymentVersionMetadataRequest.upsert_entries", + // Cloud compute API; not size-checked by the OSS server. + "temporal.api.compute.v1.ComputeProvider.details", + "temporal.api.compute.v1.ComputeScaler.details", + }; + + // --- Policy / target model ------------------------------------------------- + + enum LimitClass { + BLOB, + MEMO; + + String token() { + return this == BLOB ? "LimitClass.BLOB" : "LimitClass.MEMO"; + } + } + + /** + * Classification of a leaf field, or {@link #NOT_VALIDATED} for a field the server doesn't check. + */ + static final class FieldPolicy { + static final FieldPolicy NOT_VALIDATED = new FieldPolicy(null, false, false); + + final boolean validated; + final LimitClass limitClass; + final boolean enforceError; + + private FieldPolicy(LimitClass limitClass, boolean enforceError, boolean validated) { + this.limitClass = limitClass; + this.enforceError = enforceError; + this.validated = validated; + } + + static FieldPolicy validated(LimitClass limitClass, boolean enforceError) { + return new FieldPolicy(limitClass, enforceError, true); + } + } + + /** How a leaf field's size is measured; derived mechanically from the proto shape. */ + enum LeafKind { + SINGLE_PAYLOADS, + SINGLE_PAYLOAD, + REPEATED_PAYLOAD, + SINGLE_MEMO, + /** A Memo validated against the blob limit: measured as the data-sum of its fields. */ + MEMO_FIELDS_DATA_SUM, + SINGLE_HEADER, + SINGLE_SEARCH_ATTRIBUTES, + MAP_PAYLOAD, + MAP_PAYLOADS, + /** A whole message measured by its serialized proto size (e.g. Failure). */ + WHOLE_MESSAGE, + /** A repeated whole message, summed. */ + REPEATED_WHOLE_MESSAGE + } + + enum StructShape { + SINGLE, + REPEATED, + MAP + } + + /** Where a field leads: a measured leaf, a recurse-into struct, or ignored. */ + static final class Target { + static final Target SKIP = new Target(null, null, null); + + final LeafKind leaf; // non-null for a leaf + final StructShape structShape; // non-null for a struct + final Descriptor child; // struct child message descriptor + + private Target(LeafKind leaf, StructShape structShape, Descriptor child) { + this.leaf = leaf; + this.structShape = structShape; + this.child = child; + } + + static Target leaf(LeafKind kind) { + return new Target(kind, null, null); + } + + static Target struct(StructShape shape, Descriptor child) { + return new Target(null, shape, child); + } + } + + // --- Entry point ----------------------------------------------------------- + + /** Proto files whose services seed the walk. */ + static final String WORKFLOW_SERVICE_PROTO = "temporal/api/workflowservice/v1/service.proto"; + + static final String OPERATOR_SERVICE_PROTO = "temporal/api/operatorservice/v1/service.proto"; + + public static void main(String[] args) throws Exception { + if (args.length < 2) { + throw new IllegalArgumentException( + "usage: PayloadLimitValidatorGenerator "); + } + new PayloadLimitValidatorGenerator().run(Paths.get(args[0]), Paths.get(args[1])); + } + + private ProtoClosure closure; + private final Map table = loadTable(); + private final Set usedKeys = new HashSet<>(); + private final Set unclassified = new TreeSet<>(); + private final Set extraLeafOwners = new HashSet<>(); + + /** + * Full names of the messages that can produce at least one check; see {@link #validatingClosure}. + */ + private Set validating = new HashSet<>(); + + void run(Path descriptorSetFile, Path outputRoot) + throws IOException, DescriptorValidationException { + Map files = ProtoDescriptorSets.load(descriptorSetFile); + List seeds = + Arrays.asList( + ProtoDescriptorSets.require(files, WORKFLOW_SERVICE_PROTO), + ProtoDescriptorSets.require(files, OPERATOR_SERVICE_PROTO)); + this.closure = ProtoClosure.of(seeds); + + for (String path : EXTRA_WHOLE_MESSAGE_LEAVES) { + int dot = path.lastIndexOf('.'); + extraLeafOwners.add(path.substring(0, dot)); + } + + // Roots: the RPC input (request) message of every method of the seed services. + Set roots = new LinkedHashSet<>(); + for (FileDescriptor seed : seeds) { + for (ServiceDescriptor service : seed.getServices()) { + for (MethodDescriptor method : service.getMethods()) { + roots.add(method.getInputType()); + } + } + } + + // Closure of payload-bearing messages reachable from the roots, keyed for deterministic output. + Map toGenerate = new TreeMap<>(); + for (Descriptor d : limitsClosure(roots)) { + toGenerate.put(d.getFullName(), d); + } + + // Classify every leaf in the closure before pruning below, so the table guards always see the + // full set of payload-bearing fields regardless of what ends up being emitted. + for (Descriptor d : toGenerate.values()) { + for (Leaf leaf : leavesOf(d)) { + leafPolicy(d.getFullName() + "." + leaf.field.getName()); + } + } + + // Fail the build on any unclassified payload-bearing field. + if (!unclassified.isEmpty()) { + StringBuilder msg = new StringBuilder(); + msg.append("payload-limits: ") + .append(unclassified.size()) + .append( + " payload-bearing field(s) are not classified. Add each to the right *_FIELDS list " + + "(BLOB_FIELDS / MEMO_FIELDS / BLOB_WARN_FIELDS / NOT_VALIDATED_FIELDS) in " + + "PayloadLimitValidatorGenerator:\n"); + for (String p : unclassified) { + msg.append(" \"").append(p).append("\",\n"); + } + throw new IllegalStateException(msg.toString()); + } + + // Fail the build on stale table entries (no longer a payload-bearing field in the closure). + Set stale = new TreeSet<>(table.keySet()); + stale.removeAll(usedKeys); + if (!stale.isEmpty()) { + StringBuilder msg = new StringBuilder(); + msg.append("payload-limits: ") + .append(stale.size()) + .append( + " stale entr(y/ies) in the *_FIELDS tables (PayloadLimitValidatorGenerator) no " + + "longer correspond to a payload-bearing field; remove them:\n"); + for (String p : stale) { + msg.append(" ").append(p).append("\n"); + } + throw new IllegalStateException(msg.toString()); + } + + // Drop the messages that can never produce a check, and with them the fields that only lead + // there. Classification above already happened, so pruning cannot weaken the build guards. + this.validating = validatingClosure(toGenerate); + toGenerate.keySet().retainAll(validating); + + String source = emit(toGenerate, roots); + + Path dir = outputRoot; + for (String part : OUTPUT_PACKAGE.split("\\.", -1)) { + dir = dir.resolve(part); + } + Files.createDirectories(dir); + Path out = dir.resolve(OUTPUT_CLASS + ".java"); + Files.write(out, source.getBytes(StandardCharsets.UTF_8)); + System.out.println( + "PayloadLimitValidatorGenerator: wrote " + toGenerate.size() + " validators to " + out); + } + + // --- Table loading --------------------------------------------------------- + + private static Map loadTable() { + Map map = new HashMap<>(); + putAll(map, BLOB_FIELDS, FieldPolicy.validated(LimitClass.BLOB, true)); + putAll(map, MEMO_FIELDS, FieldPolicy.validated(LimitClass.MEMO, true)); + putAll(map, BLOB_WARN_FIELDS, FieldPolicy.validated(LimitClass.BLOB, false)); + putAll(map, NOT_VALIDATED_FIELDS, FieldPolicy.NOT_VALIDATED); + return map; + } + + private static void putAll(Map map, String[] paths, FieldPolicy policy) { + for (String path : paths) { + if (map.put(path, policy) != null) { + throw new IllegalStateException("payload-limits: duplicate table entry for `" + path + "`"); + } + } + } + + // --- Reachability + classification ----------------------------------------- + + /** + * Whether {@code d} is part of the validated closure (payload-reachable or an extra-leaf owner). + */ + private boolean included(Descriptor d) { + return closure.reaches(d) || extraLeafOwners.contains(d.getFullName()); + } + + /** + * BFS from the roots, following payload-containing structural fields; excludes terminal leaves. + */ + private Set limitsClosure(Set roots) { + Set result = new LinkedHashSet<>(); + Set seen = new HashSet<>(); + Deque queue = new ArrayDeque<>(roots); + while (!queue.isEmpty()) { + Descriptor d = queue.poll(); + String fqn = d.getFullName(); + if (TERMINAL_LEAVES.contains(fqn) || !seen.add(fqn)) { + continue; + } + if (!included(d)) { + continue; + } + result.add(d); + for (FieldDescriptor f : d.getFields()) { + Target t = classify(f); + if (t.structShape != null && included(t.child)) { + queue.add(t.child); + } + } + } + return result; + } + + static Target classify(FieldDescriptor f) { + if (f.isMapField()) { + FieldDescriptor value = f.getMessageType().findFieldByNumber(2); + if (value.getJavaType() != FieldDescriptor.JavaType.MESSAGE) { + return Target.SKIP; + } + String name = value.getMessageType().getFullName(); + if (PAYLOAD.equals(name)) { + return Target.leaf(LeafKind.MAP_PAYLOAD); + } + if (PAYLOADS.equals(name)) { + return Target.leaf(LeafKind.MAP_PAYLOADS); + } + if (ProtoNames.isTemporal(value.getMessageType())) { + return Target.struct(StructShape.MAP, value.getMessageType()); + } + return Target.SKIP; + } + if (f.getJavaType() != FieldDescriptor.JavaType.MESSAGE) { + return Target.SKIP; + } + String name = f.getMessageType().getFullName(); + boolean repeated = f.isRepeated(); + LeafKind kind = terminalLeafKind(name, repeated); + if (kind != null) { + return Target.leaf(kind); + } + if (!ProtoNames.isTemporal(f.getMessageType())) { + return Target.SKIP; + } + return Target.struct(repeated ? StructShape.REPEATED : StructShape.SINGLE, f.getMessageType()); + } + + /** + * The leaf measurement kind for a terminal-leaf type, or {@code null} for a recurse-into message. + */ + static LeafKind terminalLeafKind(String typeName, boolean repeated) { + if (PAYLOAD.equals(typeName)) { + return repeated ? LeafKind.REPEATED_PAYLOAD : LeafKind.SINGLE_PAYLOAD; + } + if (PAYLOADS.equals(typeName)) { + return LeafKind.SINGLE_PAYLOADS; + } + if (MEMO.equals(typeName)) { + return LeafKind.SINGLE_MEMO; + } + if (HEADER.equals(typeName)) { + return LeafKind.SINGLE_HEADER; + } + if (SEARCH_ATTRIBUTES.equals(typeName)) { + return LeafKind.SINGLE_SEARCH_ATTRIBUTES; + } + if (FAILURE.equals(typeName)) { + return repeated ? LeafKind.REPEATED_WHOLE_MESSAGE : LeafKind.WHOLE_MESSAGE; + } + return null; + } + + /** A Memo validated against the blob limit is measured as its fields' data-sum. */ + static LeafKind effectiveKind(LeafKind kind, LimitClass limitClass) { + if (kind == LeafKind.SINGLE_MEMO && limitClass == LimitClass.BLOB) { + return LeafKind.MEMO_FIELDS_DATA_SUM; + } + return kind; + } + + private FieldPolicy leafPolicy(String protoPath) { + FieldPolicy policy = table.get(protoPath); + if (policy == null) { + unclassified.add(protoPath); + return null; + } + usedKeys.add(protoPath); + return policy; + } + + /** A measured leaf field of a message: the field itself, plus how its size is taken. */ + private static final class Leaf { + final FieldDescriptor field; + final LeafKind kind; + + Leaf(FieldDescriptor field, LeafKind kind) { + this.field = field; + this.kind = kind; + } + } + + /** Every measured leaf of {@code d}, in emission order: terminal leaves, then forced extras. */ + private List leavesOf(Descriptor d) { + List leaves = new ArrayList<>(); + for (FieldDescriptor f : d.getFields()) { + Target t = classify(f); + if (t.leaf != null) { + leaves.add(new Leaf(f, t.leaf)); + } + } + leaves.addAll(extraLeavesOf(d)); + return leaves; + } + + /** The {@link #EXTRA_WHOLE_MESSAGE_LEAVES} entries owned by {@code d}. */ + private List extraLeavesOf(Descriptor d) { + List leaves = new ArrayList<>(); + for (String path : EXTRA_WHOLE_MESSAGE_LEAVES) { + int dot = path.lastIndexOf('.'); + if (!path.substring(0, dot).equals(d.getFullName())) { + continue; + } + FieldDescriptor f = d.findFieldByName(path.substring(dot + 1)); + if (f != null) { + leaves.add(new Leaf(f, LeafKind.WHOLE_MESSAGE)); + } + } + return leaves; + } + + /** The struct-typed fields of {@code d} that the traversal recurses into. */ + private List structsOf(Descriptor d) { + List structs = new ArrayList<>(); + for (FieldDescriptor f : d.getFields()) { + Target t = classify(f); + if (t.structShape != null && included(t.child)) { + structs.add(t); + } + } + return structs; + } + + /** + * Messages that can produce at least one check: those with a validated leaf, plus, to a fixpoint, + * those that lead to one. Everything else traverses to nothing, so emitting it (and the fields + * that reach it) would only cost code size and per-request work. + */ + private Set validatingClosure(Map toGenerate) { + Set result = new HashSet<>(); + Map> children = new HashMap<>(); + for (Descriptor d : toGenerate.values()) { + for (Leaf leaf : leavesOf(d)) { + FieldPolicy policy = table.get(d.getFullName() + "." + leaf.field.getName()); + if (policy != null && policy.validated) { + result.add(d.getFullName()); + break; + } + } + List refs = new ArrayList<>(); + for (Target t : structsOf(d)) { + refs.add(t.child.getFullName()); + } + children.put(d.getFullName(), refs); + } + boolean changed = true; + while (changed) { + changed = false; + for (Map.Entry> e : children.entrySet()) { + if (result.contains(e.getKey())) { + continue; + } + for (String child : e.getValue()) { + if (result.contains(child)) { + result.add(e.getKey()); + changed = true; + break; + } + } + } + } + return result; + } + + // --- Emission -------------------------------------------------------------- + + private String emit(Map toGenerate, Set roots) { + StringBuilder sb = new StringBuilder(); + sb.append("// Code generated by PayloadLimitValidatorGenerator; DO NOT EDIT.\n"); + sb.append("package ").append(OUTPUT_PACKAGE).append(";\n\n"); + sb.append("import com.google.protobuf.Message;\n"); + sb.append("import java.util.HashMap;\n"); + sb.append("import java.util.List;\n"); + sb.append("import java.util.Map;\n"); + sb.append("import java.util.function.BiConsumer;\n\n"); + sb.append("@SuppressWarnings(\"deprecation\")\n"); + sb.append("final class ").append(OUTPUT_CLASS).append(" {\n"); + sb.append(" private ").append(OUTPUT_CLASS).append("() {}\n\n"); + + // Dispatcher over the payload-bearing request roots. + List rootList = new ArrayList<>(); + Set rootSeen = new HashSet<>(); + for (Descriptor r : roots) { + if (toGenerate.containsKey(r.getFullName()) && rootSeen.add(r.getFullName())) { + rootList.add(r); + } + } + rootList.sort((a, b) -> a.getFullName().compareTo(b.getFullName())); + // Keyed on the exact class: generated proto message classes are final, so there are no + // subtypes to widen for, and a single hash lookup beats a linear chain of instanceof tests -- + // above all for the requests that carry no payloads, which is most of them. + sb.append(" private static final Map, BiConsumer>") + .append(" DISPATCH = new HashMap<>();\n\n"); + sb.append(" static {\n"); + for (Descriptor r : rootList) { + String src = ProtoNames.sourceClassName(r); + sb.append(" DISPATCH.put(") + .append(src) + .append(".class, (sink, msg) -> ") + .append(ProtoNames.methodName(r.getFullName())) + .append("(sink, (") + .append(src) + .append(") msg));\n"); + } + sb.append(" }\n\n"); + sb.append(" static void dispatch(PayloadLimitSink sink, Message request) {\n"); + sb.append(" BiConsumer validator") + .append(" = DISPATCH.get(request.getClass());\n"); + sb.append(" if (validator != null) {\n"); + sb.append(" validator.accept(sink, request);\n"); + sb.append(" }\n"); + sb.append(" }\n\n"); + + for (Descriptor d : toGenerate.values()) { + emitValidateMethod(sb, d); + } + + sb.append("}\n"); + return sb.toString(); + } + + private void emitValidateMethod(StringBuilder sb, Descriptor d) { + String src = ProtoNames.sourceClassName(d); + sb.append(" static void ") + .append(ProtoNames.methodName(d.getFullName())) + .append("(PayloadLimitSink sink, ") + .append(src) + .append(" msg) {\n"); + int fi = 0; + for (FieldDescriptor f : d.getFields()) { + Target t = classify(f); + if (t.leaf != null) { + emitLeaf(sb, d.getFullName(), f, t.leaf); + } else if (t.structShape != null + && included(t.child) + && validating.contains(t.child.getFullName())) { + emitStruct(sb, f, t.structShape, t.child, fi++); + } + } + // Extra whole-message leaves whose owner is this message. + for (Leaf leaf : extraLeavesOf(d)) { + emitLeaf(sb, d.getFullName(), leaf.field, leaf.kind); + } + sb.append(" }\n\n"); + } + + private void emitLeaf(StringBuilder sb, String ownerFqn, FieldDescriptor f, LeafKind kind) { + String protoField = f.getName(); + FieldPolicy policy = leafPolicy(ownerFqn + "." + protoField); + if (policy == null || !policy.validated) { + return; // unclassified (build fails) or NotValidated (no check) + } + LeafKind effective = effectiveKind(kind, policy.limitClass); + String base = ProtoNames.base(f); + String classToken = policy.limitClass.token(); + String ee = String.valueOf(policy.enforceError); + switch (effective) { + case SINGLE_PAYLOADS: + case SINGLE_PAYLOAD: + case SINGLE_MEMO: + case MEMO_FIELDS_DATA_SUM: + case SINGLE_HEADER: + case SINGLE_SEARCH_ATTRIBUTES: + case WHOLE_MESSAGE: + sb.append(" if (msg.has").append(base).append("()) {\n"); + sb.append(" sink.check(\"") + .append(protoField) + .append("\", ") + .append(classToken) + .append(", ") + .append(singleSizeExpr(effective, "msg.get" + base + "()")) + .append(", ") + .append(ee) + .append(");\n"); + sb.append(" }\n"); + return; + case REPEATED_PAYLOAD: + case REPEATED_WHOLE_MESSAGE: + sb.append(" sink.check(\"") + .append(protoField) + .append("\", ") + .append(classToken) + .append(", PayloadLimitSizes.serializedSizeSum(msg.get") + .append(base) + .append("List()), ") + .append(ee) + .append(");\n"); + return; + case MAP_PAYLOAD: + sb.append(" sink.check(\"") + .append(protoField) + .append("\", ") + .append(classToken) + .append(", PayloadLimitSizes.mapPayloadDataSum(msg.get") + .append(base) + .append("Map()), ") + .append(ee) + .append(");\n"); + return; + case MAP_PAYLOADS: + sb.append(" sink.check(\"") + .append(protoField) + .append("\", ") + .append(classToken) + .append(", PayloadLimitSizes.mapPayloadsSum(msg.get") + .append(base) + .append("Map()), ") + .append(ee) + .append(");\n"); + return; + } + throw new AssertionError(effective); + } + + /** Size expression for an optional-singular leaf, given the getter expression for the value. */ + private static String singleSizeExpr(LeafKind kind, String getter) { + switch (kind) { + case SINGLE_PAYLOADS: + case SINGLE_PAYLOAD: + case SINGLE_MEMO: + case WHOLE_MESSAGE: + return "PayloadLimitSizes.serializedSize(" + getter + ")"; + case MEMO_FIELDS_DATA_SUM: + case SINGLE_HEADER: + return "PayloadLimitSizes.mapPayloadDataSum(" + getter + ".getFieldsMap())"; + case SINGLE_SEARCH_ATTRIBUTES: + return "PayloadLimitSizes.mapPayloadDataSum(" + getter + ".getIndexedFieldsMap())"; + case REPEATED_PAYLOAD: + case REPEATED_WHOLE_MESSAGE: + case MAP_PAYLOAD: + case MAP_PAYLOADS: + break; + } + throw new AssertionError(kind); + } + + private void emitStruct( + StringBuilder sb, FieldDescriptor f, StructShape shape, Descriptor child, int fi) { + String protoField = f.getName(); + String base = ProtoNames.base(f); + String childMethod = ProtoNames.methodName(child.getFullName()); + String childSrc = ProtoNames.sourceClassName(child); + switch (shape) { + case SINGLE: + sb.append(" if (msg.has").append(base).append("()) {\n"); + sb.append(" sink.enter(\"").append(protoField).append("\");\n"); + sb.append(" ") + .append(childMethod) + .append("(sink, msg.get") + .append(base) + .append("());\n"); + sb.append(" sink.exit();\n"); + sb.append(" }\n"); + return; + case REPEATED: + sb.append(" {\n"); + sb.append(" List<") + .append(childSrc) + .append("> __l") + .append(fi) + .append(" = msg.get") + .append(base) + .append("List();\n"); + sb.append(" for (int __i") + .append(fi) + .append(" = 0; __i") + .append(fi) + .append(" < __l") + .append(fi) + .append(".size(); __i") + .append(fi) + .append("++) {\n"); + sb.append(" sink.enter(\"") + .append(protoField) + .append("\", __i") + .append(fi) + .append(");\n"); + sb.append(" ") + .append(childMethod) + .append("(sink, __l") + .append(fi) + .append(".get(__i") + .append(fi) + .append("));\n"); + sb.append(" sink.exit();\n"); + sb.append(" }\n"); + sb.append(" }\n"); + return; + case MAP: + sb.append(" for (Map.Entry __e") + .append(fi) + .append(" : msg.get") + .append(base) + .append("Map().entrySet()) {\n"); + sb.append(" sink.enter(\"") + .append(protoField) + .append("\", __e") + .append(fi) + .append(".getKey());\n"); + sb.append(" ") + .append(childMethod) + .append("(sink, __e") + .append(fi) + .append(".getValue());\n"); + sb.append(" sink.exit();\n"); + sb.append(" }\n"); + return; + } + throw new AssertionError(shape); + } +} diff --git a/temporal-serviceclient/src/payloadLimitsGenerator/java/io/temporal/internal/payload/limits/gen/ProtoClosure.java b/temporal-serviceclient/src/payloadLimitsGenerator/java/io/temporal/internal/payload/limits/gen/ProtoClosure.java new file mode 100644 index 0000000000..038d53376f --- /dev/null +++ b/temporal-serviceclient/src/payloadLimitsGenerator/java/io/temporal/internal/payload/limits/gen/ProtoClosure.java @@ -0,0 +1,178 @@ +package io.temporal.internal.payload.limits.gen; + +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.Descriptors.FileDescriptor; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The message closure reachable from a set of seed services, and which of those messages can + * transitively contain a {@code Payload}. + * + *

Reachability is deliberately the same notion the payload visitor uses: a {@code + * google.protobuf.Any} counts as payload-bearing, because its contents are opaque here and may hold + * payloads. + * + *

A near-copy of this class exists in {@code temporal-sdk}, as {@code + * io.temporal.internal.payload.visitor.gen.ProtoClosure}, serving the payload visitor. See that + * class for why the duplication is deliberate. The copies differ only in how each obtains its + * {@code Descriptor}s (a descriptor set file here, compiled proto classes there) and in that this + * one computes reachability without the visitor generator's {@code FieldPlan}/{@code classify} + * model. Payload reachability must stay identical in both; verify any change by confirming + * both generators' output is byte-identical before and after it. + */ +final class ProtoClosure { + + private static final String PAYLOAD = "temporal.api.common.v1.Payload"; + private static final String PAYLOADS = "temporal.api.common.v1.Payloads"; + private static final String ANY = "google.protobuf.Any"; + + /** All non-map-entry messages in the closure, in discovery order. */ + final List allMessages; + + /** Full names of the messages that can transitively contain a payload. */ + private final Set reaches; + + private ProtoClosure(List allMessages, Set reaches) { + this.allMessages = allMessages; + this.reaches = reaches; + } + + /** Whether {@code d} can transitively contain a payload. */ + boolean reaches(Descriptor d) { + return reaches.contains(d.getFullName()); + } + + /** Builds the closure and payload-reachability set from the given seed file descriptors. */ + static ProtoClosure of(List seeds) { + List all = collectMessages(fileClosure(seeds)); + return new ProtoClosure(all, computeReachability(all)); + } + + // --- Descriptor discovery --- + + private static Set fileClosure(List seeds) { + Set seen = new LinkedHashSet<>(); + Deque queue = new ArrayDeque<>(seeds); + while (!queue.isEmpty()) { + FileDescriptor f = queue.poll(); + if (seen.add(f)) { + queue.addAll(f.getDependencies()); + } + } + return seen; + } + + private static List collectMessages(Set files) { + List result = new ArrayList<>(); + for (FileDescriptor f : files) { + for (Descriptor d : f.getMessageTypes()) { + collectMessages(d, result); + } + } + return result; + } + + private static void collectMessages(Descriptor d, List out) { + if (d.getOptions().getMapEntry()) { + return; // synthetic map entry type; handled via the owning map field + } + out.add(d); + for (Descriptor nested : d.getNestedTypes()) { + collectMessages(nested, out); + } + } + + // --- Reachability --- + + /** + * Least-fixpoint reachability over the message-reference graph. A message reaches a payload if it + * has a direct payload/Any field, or it references (via a message or map-message field) another + * message that does. Iterating to a fixpoint handles cycles (e.g. {@code Failure.cause}) + * correctly without over-approximating payload-free cycles. + */ + private static Set computeReachability(List all) { + Set reaches = new HashSet<>(); + Map> children = new HashMap<>(); + for (Descriptor d : all) { + boolean direct = false; + List refs = new ArrayList<>(); + for (FieldDescriptor f : d.getFields()) { + Descriptor referenced = referencedMessage(f); + if (referenced == null) { + if (carriesPayloadDirectly(f)) { + direct = true; + } + } else { + refs.add(referenced); + } + } + if (direct) { + reaches.add(d.getFullName()); + } + children.put(d.getFullName(), refs); + } + boolean changed = true; + while (changed) { + changed = false; + for (Descriptor d : all) { + if (reaches.contains(d.getFullName())) { + continue; + } + for (Descriptor c : children.get(d.getFullName())) { + if (reaches.contains(c.getFullName())) { + reaches.add(d.getFullName()); + changed = true; + break; + } + } + } + } + return reaches; + } + + /** Whether {@code f} holds payload data itself, in singular, repeated or map-valued form. */ + private static boolean carriesPayloadDirectly(FieldDescriptor f) { + String name = valueMessageName(f); + return PAYLOAD.equals(name) || PAYLOADS.equals(name) || ANY.equals(name); + } + + /** + * The Temporal message {@code f} refers to and should be recursed into, or {@code null} if it + * carries payload data directly or leads nowhere interesting. + */ + private static Descriptor referencedMessage(FieldDescriptor f) { + if (carriesPayloadDirectly(f)) { + return null; + } + Descriptor value = valueMessage(f); + if (value == null || !ProtoNames.isTemporal(value)) { + return null; + } + return value; + } + + /** The message type a field holds, unwrapping map values; {@code null} for non-message fields. */ + private static Descriptor valueMessage(FieldDescriptor f) { + if (f.isMapField()) { + FieldDescriptor value = f.getMessageType().findFieldByNumber(2); + return value.getJavaType() == FieldDescriptor.JavaType.MESSAGE + ? value.getMessageType() + : null; + } + return f.getJavaType() == FieldDescriptor.JavaType.MESSAGE ? f.getMessageType() : null; + } + + private static String valueMessageName(FieldDescriptor f) { + Descriptor value = valueMessage(f); + return value == null ? null : value.getFullName(); + } +} diff --git a/temporal-serviceclient/src/payloadLimitsGenerator/java/io/temporal/internal/payload/limits/gen/ProtoDescriptorSets.java b/temporal-serviceclient/src/payloadLimitsGenerator/java/io/temporal/internal/payload/limits/gen/ProtoDescriptorSets.java new file mode 100644 index 0000000000..cb32bf3d1f --- /dev/null +++ b/temporal-serviceclient/src/payloadLimitsGenerator/java/io/temporal/internal/payload/limits/gen/ProtoDescriptorSets.java @@ -0,0 +1,96 @@ +package io.temporal.internal.payload.limits.gen; + +import com.google.protobuf.DescriptorProtos.FileDescriptorProto; +import com.google.protobuf.DescriptorProtos.FileDescriptorSet; +import com.google.protobuf.Descriptors.DescriptorValidationException; +import com.google.protobuf.Descriptors.FileDescriptor; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Loads {@link FileDescriptor}s from a {@code protoc}-emitted descriptor set file. + * + *

The generator reads descriptors from a file rather than from compiled proto classes (via + * {@code SomeProto.getDescriptor()}) so that it does not depend on this module's own compiled + * output. The protos are generated into this module's main source set, so a generator that needed + * the compiled classes would form a dependency cycle with the very compilation that consumes its + * output. + */ +final class ProtoDescriptorSets { + private ProtoDescriptorSets() {} + + /** + * Loads every file in {@code descriptorSetFile}, keyed by proto file name (e.g. {@code + * temporal/api/workflowservice/v1/service.proto}). + * + *

The descriptor set must have been generated with imports included; a file referenced as a + * dependency but absent from the set is an error. + */ + static Map load(Path descriptorSetFile) + throws IOException, DescriptorValidationException { + FileDescriptorSet set; + try (InputStream in = Files.newInputStream(descriptorSetFile)) { + // Custom options are left as unknown fields; only standard options are read here. + set = FileDescriptorSet.parseFrom(in); + } + Map protos = new LinkedHashMap<>(); + for (FileDescriptorProto proto : set.getFileList()) { + // A file can appear more than once when several roots import it; the copies are identical. + protos.putIfAbsent(proto.getName(), proto); + } + Map built = new LinkedHashMap<>(); + for (String name : protos.keySet()) { + build(name, protos, built, new LinkedHashSet<>()); + } + return built; + } + + /** Builds {@code name} and, depth-first, the files it imports. */ + private static FileDescriptor build( + String name, + Map protos, + Map built, + Set building) + throws DescriptorValidationException { + FileDescriptor existing = built.get(name); + if (existing != null) { + return existing; + } + if (!building.add(name)) { + throw new IllegalStateException("cyclic proto import involving `" + name + "`"); + } + FileDescriptorProto proto = protos.get(name); + if (proto == null) { + throw new IllegalStateException( + "descriptor set is missing imported file `" + + name + + "`; it must be generated with descriptorSetOptions.includeImports = true"); + } + List dependencies = new ArrayList<>(); + for (String dependency : proto.getDependencyList()) { + dependencies.add(build(dependency, protos, built, building)); + } + FileDescriptor file = + FileDescriptor.buildFrom(proto, dependencies.toArray(new FileDescriptor[0])); + building.remove(name); + built.put(name, file); + return file; + } + + /** Looks up a file that must be present, with a message naming the missing file if it is not. */ + static FileDescriptor require(Map files, String name) { + FileDescriptor file = files.get(name); + if (file == null) { + throw new IllegalStateException("descriptor set does not contain `" + name + "`"); + } + return file; + } +} diff --git a/temporal-serviceclient/src/payloadLimitsGenerator/java/io/temporal/internal/payload/limits/gen/ProtoNames.java b/temporal-serviceclient/src/payloadLimitsGenerator/java/io/temporal/internal/payload/limits/gen/ProtoNames.java new file mode 100644 index 0000000000..2964eed986 --- /dev/null +++ b/temporal-serviceclient/src/payloadLimitsGenerator/java/io/temporal/internal/payload/limits/gen/ProtoNames.java @@ -0,0 +1,77 @@ +package io.temporal.internal.payload.limits.gen; + +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.ArrayDeque; +import java.util.Deque; + +/** + * Java naming rules for generated code, mirroring what {@code protoc} produces for a descriptor. + * + *

These duplicate the naming helpers in {@code temporal-sdk}'s {@code + * io.temporal.internal.payload.visitor.gen.PayloadVisitorGenerator}; see {@link ProtoClosure} for + * why the two generators are deliberately independent. Drift here is far less dangerous than drift + * in reachability: a wrong accessor name produces generated code that does not compile. + */ +final class ProtoNames { + private ProtoNames() {} + + /** Whether {@code d} is a Temporal-owned message (as opposed to a well-known/3rd-party type). */ + static boolean isTemporal(Descriptor d) { + return d.getFullName().startsWith("temporal."); + } + + /** Mirrors protoc's {@code UnderscoresToCamelCase}, used to derive Java accessor names. */ + static String camel(String input, boolean capNext) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + if (c >= 'a' && c <= 'z') { + sb.append(capNext ? Character.toUpperCase(c) : c); + capNext = false; + } else if (c >= 'A' && c <= 'Z') { + if (i == 0 && !capNext) { + sb.append(Character.toLowerCase(c)); + } else { + sb.append(c); + } + capNext = false; + } else if (c >= '0' && c <= '9') { + sb.append(c); + capNext = true; + } else { + capNext = true; + } + } + return sb.toString(); + } + + /** Capitalized accessor base, e.g. {@code schedule_activity} -> {@code ScheduleActivity}. */ + static String base(FieldDescriptor f) { + return camel(f.getName(), true); + } + + private static String javaPackage(Descriptor d) { + String pkg = d.getFile().getOptions().getJavaPackage(); + if (pkg == null || pkg.isEmpty()) { + throw new IllegalStateException("message " + d.getFullName() + " has no java_package option"); + } + return pkg; + } + + /** + * Source-form class name, e.g. {@code io.temporal.api.common.v1.Payload.ExternalPayloadDetails}. + */ + static String sourceClassName(Descriptor d) { + Deque names = new ArrayDeque<>(); + for (Descriptor c = d; c != null; c = c.getContainingType()) { + names.addFirst(c.getName()); + } + return javaPackage(d) + "." + String.join(".", names); + } + + /** Name of the generated per-message method for {@code full}, a descriptor full name. */ + static String methodName(String full) { + return "visit_" + full.replace('.', '_'); + } +} diff --git a/temporal-serviceclient/src/test/java/io/temporal/internal/payload/limits/PayloadLimitValidatorTest.java b/temporal-serviceclient/src/test/java/io/temporal/internal/payload/limits/PayloadLimitValidatorTest.java new file mode 100644 index 0000000000..691c610961 --- /dev/null +++ b/temporal-serviceclient/src/test/java/io/temporal/internal/payload/limits/PayloadLimitValidatorTest.java @@ -0,0 +1,458 @@ +package io.temporal.internal.payload.limits; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.Any; +import com.google.protobuf.ByteString; +import io.temporal.api.command.v1.Command; +import io.temporal.api.command.v1.CompleteWorkflowExecutionCommandAttributes; +import io.temporal.api.command.v1.ContinueAsNewWorkflowExecutionCommandAttributes; +import io.temporal.api.command.v1.FailWorkflowExecutionCommandAttributes; +import io.temporal.api.command.v1.ModifyWorkflowPropertiesCommandAttributes; +import io.temporal.api.command.v1.RecordMarkerCommandAttributes; +import io.temporal.api.command.v1.ScheduleActivityTaskCommandAttributes; +import io.temporal.api.command.v1.ScheduleNexusOperationCommandAttributes; +import io.temporal.api.common.v1.Header; +import io.temporal.api.common.v1.Memo; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.common.v1.SearchAttributes; +import io.temporal.api.failure.v1.Failure; +import io.temporal.api.protocol.v1.Message; +import io.temporal.api.query.v1.WorkflowQueryResult; +import io.temporal.api.sdk.v1.UserMetadata; +import io.temporal.api.update.v1.Input; +import io.temporal.api.update.v1.Request; +import io.temporal.api.workflowservice.v1.DescribeWorkflowExecutionRequest; +import io.temporal.api.workflowservice.v1.RespondActivityTaskFailedRequest; +import io.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest; +import io.temporal.api.workflowservice.v1.StartWorkflowExecutionRequest; +import io.temporal.api.workflowservice.v1.TerminateWorkflowExecutionRequest; +import io.temporal.api.workflowservice.v1.UpdateWorkflowExecutionRequest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.Test; + +public class PayloadLimitValidatorTest { + + // --- helpers --------------------------------------------------------------- + + private static Payload payload(int dataLen) { + return Payload.newBuilder().setData(ByteString.copyFrom(new byte[dataLen])).build(); + } + + private static Payloads payloads(int dataLen) { + return Payloads.newBuilder().addPayloads(payload(dataLen)).build(); + } + + private static Memo memoWith(String key, int dataLen) { + return Memo.newBuilder().putFields(key, payload(dataLen)).build(); + } + + /** blobWarn = memoWarn = 10, with the given error thresholds. */ + private static PayloadLimits workerLimits(long blobError, long memoError) { + return new PayloadLimits(10, blobError, 10, memoError); + } + + private static RespondWorkflowTaskCompletedRequest wftWithCommand(Command command) { + return RespondWorkflowTaskCompletedRequest.newBuilder().addCommands(command).build(); + } + + // --- end-to-end validation via PayloadLimitValidator ----------------------- + + @Test + public void blobFieldOverErrorLimitIsReported() { + StartWorkflowExecutionRequest req = + StartWorkflowExecutionRequest.newBuilder().setInput(payloads(1000)).build(); + Optional v = PayloadLimitValidator.validate(req, workerLimits(100, 100)); + assertTrue(v.isPresent()); + assertEquals(LimitClass.BLOB, v.get().getLimitClass()); + assertEquals(LimitSeverity.ERROR, v.get().getSeverity()); + assertEquals("input", v.get().getPath()); + assertTrue(v.get().getSize() > 100); + } + + @Test + public void memoFieldUsesMemoLimit() { + // A memo over the memo error limit but well under the (huge) blob error limit must still error, + // proving the memo class routes to the memo threshold. + StartWorkflowExecutionRequest req = + StartWorkflowExecutionRequest.newBuilder().setMemo(memoWith("k", 50)).build(); + PayloadLimits limits = new PayloadLimits(10, 1_000_000, 10, 20); + Optional v = PayloadLimitValidator.validate(req, limits); + assertTrue(v.isPresent()); + assertEquals(LimitClass.MEMO, v.get().getLimitClass()); + assertEquals("memo", v.get().getPath()); + } + + @Test + public void warnOnlyClassifiedFieldNeverErrors() { + // RespondActivityTaskFailed.failure is classified warn-only, so even with an error limit a huge + // failure produces no error-level violation. + RespondActivityTaskFailedRequest req = + RespondActivityTaskFailedRequest.newBuilder() + .setFailure(Failure.newBuilder().setMessage(repeat("x", 10_000))) + .build(); + assertFalse(PayloadLimitValidator.validate(req, workerLimits(100, 100)).isPresent()); + } + + @Test + public void underLimitIsOk() { + StartWorkflowExecutionRequest req = + StartWorkflowExecutionRequest.newBuilder().setInput(payloads(5)).build(); + assertFalse(PayloadLimitValidator.validate(req, workerLimits(100_000, 100_000)).isPresent()); + } + + @Test + public void requestWithoutPayloadFieldsIsIgnored() { + assertFalse( + PayloadLimitValidator.validate( + DescribeWorkflowExecutionRequest.getDefaultInstance(), workerLimits(1, 1)) + .isPresent()); + } + + @Test + public void blobClassedMemoIsMeasuredAsFieldsDataSum() { + // ModifyWorkflowProperties.upserted_memo is blob-classed, so it is measured as the data-sum of + // its fields (key bytes + payload data bytes), NOT the whole-Memo proto size. + Memo memo = + Memo.newBuilder().putFields("ab", payload(10)).putFields("cde", payload(20)).build(); + RespondWorkflowTaskCompletedRequest req = + wftWithCommand( + Command.newBuilder() + .setModifyWorkflowPropertiesCommandAttributes( + ModifyWorkflowPropertiesCommandAttributes.newBuilder().setUpsertedMemo(memo)) + .build()); + // data-sum = (2 + 10) + (3 + 20) = 35; blobError = 30 -> error, classified BLOB. + Optional v = + PayloadLimitValidator.validate(req, workerLimits(30, 1_000_000)); + assertTrue(v.isPresent()); + assertEquals(LimitClass.BLOB, v.get().getLimitClass()); + assertEquals( + "commands[0].modify_workflow_properties_command_attributes.upserted_memo", + v.get().getPath()); + assertEquals(35, v.get().getSize()); + } + + @Test + public void markerDetailsMapIsMeasuredAsPayloadsSum() { + RespondWorkflowTaskCompletedRequest req = + wftWithCommand( + Command.newBuilder() + .setRecordMarkerCommandAttributes( + RecordMarkerCommandAttributes.newBuilder().putDetails("marker", payloads(1000))) + .build()); + Optional v = PayloadLimitValidator.validate(req, workerLimits(100, 100)); + assertTrue(v.isPresent()); + assertEquals(LimitClass.BLOB, v.get().getLimitClass()); + assertEquals("commands[0].record_marker_command_attributes.details", v.get().getPath()); + } + + @Test + public void singlePayloadFieldIsMeasuredAsPayloadSize() { + // ScheduleNexusOperation.input is a single Payload (not Payloads). + RespondWorkflowTaskCompletedRequest req = + wftWithCommand( + Command.newBuilder() + .setScheduleNexusOperationCommandAttributes( + ScheduleNexusOperationCommandAttributes.newBuilder().setInput(payload(1000))) + .build()); + Optional v = PayloadLimitValidator.validate(req, workerLimits(100, 100)); + assertTrue(v.isPresent()); + assertEquals(LimitClass.BLOB, v.get().getLimitClass()); + assertEquals( + "commands[0].schedule_nexus_operation_command_attributes.input", v.get().getPath()); + } + + @Test + public void wholeFailureIsMeasuredAsMessageSize() { + RespondWorkflowTaskCompletedRequest req = + wftWithCommand( + Command.newBuilder() + .setFailWorkflowExecutionCommandAttributes( + FailWorkflowExecutionCommandAttributes.newBuilder() + .setFailure(Failure.newBuilder().setMessage(repeat("x", 1000)))) + .build()); + Optional v = PayloadLimitValidator.validate(req, workerLimits(100, 100)); + assertTrue(v.isPresent()); + assertEquals(LimitClass.BLOB, v.get().getLimitClass()); + assertEquals( + "commands[0].fail_workflow_execution_command_attributes.failure", v.get().getPath()); + } + + @Test + public void tmprl1103MessageText() { + StartWorkflowExecutionRequest req = + StartWorkflowExecutionRequest.newBuilder().setInput(payloads(1000)).build(); + PayloadLimitViolation v = PayloadLimitValidator.validate(req, workerLimits(100, 100)).get(); + assertEquals( + "[TMPRL1103] Attempted to upload payloads with size that exceeded the error limit.", + v.getMessage()); + } + + // --- CollectingSink: classification, independent of logging/early-return ---- + + @Test + public void collectingSinkClassifiesErrorVsWarning() { + CollectingSink sink = new CollectingSink(workerLimits(100, 100)); // blobWarn=10, blobError=100 + sink.check("over_error", LimitClass.BLOB, 200, true); + sink.check("over_warn", LimitClass.BLOB, 50, true); + sink.check("under_warn", LimitClass.BLOB, 5, true); + assertEquals(1, sink.getErrors().size()); + assertEquals("over_error", sink.getErrors().get(0).getPath()); + assertEquals(100, sink.getErrors().get(0).getLimit()); + assertEquals(1, sink.getWarnings().size()); + assertEquals("over_warn", sink.getWarnings().get(0).getPath()); + assertEquals(10, sink.getWarnings().get(0).getLimit()); + } + + @Test + public void collectingSinkWarnOnlyFieldNeverErrors() { + CollectingSink sink = new CollectingSink(workerLimits(100, 100)); + sink.check("warn_only", LimitClass.BLOB, 5000, false); // enforceError = false + assertTrue(sink.getErrors().isEmpty()); + assertEquals(1, sink.getWarnings().size()); + } + + @Test + public void collectingSinkNoErrorLimitOnlyWarns() { + CollectingSink sink = new CollectingSink(new PayloadLimits(100, 0, 0, 0)); + sink.check("big", LimitClass.BLOB, 101, true); // error threshold 0 disables errors + assertTrue(sink.getErrors().isEmpty()); + assertEquals(1, sink.getWarnings().size()); + } + + @Test + public void collectingSinkZeroWarnDisablesWarnings() { + CollectingSink sink = new CollectingSink(PayloadLimits.none()); + sink.check("big", LimitClass.BLOB, 5000, true); + assertTrue(sink.getErrors().isEmpty()); + assertTrue(sink.getWarnings().isEmpty()); + } + + @Test + public void collectingSinkRoutesMemoToMemoLimit() { + CollectingSink sink = new CollectingSink(workerLimits(1_000_000, 20)); + sink.check("blob_field", LimitClass.BLOB, 100, true); // fine: huge blob limit + sink.check("memo_field", LimitClass.MEMO, 100, true); // errors: tiny memo limit + assertEquals(1, sink.getErrors().size()); + assertEquals(LimitClass.MEMO, sink.getErrors().get(0).getLimitClass()); + assertEquals("memo_field", sink.getErrors().get(0).getPath()); + } + + // --- Which fields get visited (order-independent) -------------------------- + + @Test + public void visitsPayloadFieldsOfEachCommandWithPaths() { + RespondWorkflowTaskCompletedRequest req = + RespondWorkflowTaskCompletedRequest.newBuilder() + .addCommands( + Command.newBuilder() + .setScheduleActivityTaskCommandAttributes( + ScheduleActivityTaskCommandAttributes.newBuilder().setInput(payloads(1)))) + .addCommands( + Command.newBuilder() + .setCompleteWorkflowExecutionCommandAttributes( + CompleteWorkflowExecutionCommandAttributes.newBuilder() + .setResult(payloads(1)))) + .build(); + RecordingSink sink = new RecordingSink(); + GeneratedPayloadLimitValidator.dispatch(sink, req); + assertEquals( + Arrays.asList( + "commands[0].schedule_activity_task_command_attributes.input", + "commands[1].complete_workflow_execution_command_attributes.result"), + sink.sorted()); + } + + @Test + public void visitsOnlyPresentFields() { + // Only input is set; memo/search_attributes/etc. are absent and must not be visited. + StartWorkflowExecutionRequest req = + StartWorkflowExecutionRequest.newBuilder().setInput(payloads(1)).build(); + RecordingSink sink = new RecordingSink(); + GeneratedPayloadLimitValidator.dispatch(sink, req); + assertEquals(Collections.singletonList("input"), sink.sorted()); + } + + @Test + public void visitsProtocolMessageBody() { + // Message.body is a google.protobuf.Any reached because Message is a forced whole-message leaf + // and the parent recurses into `messages`. + RespondWorkflowTaskCompletedRequest req = + RespondWorkflowTaskCompletedRequest.newBuilder() + .addMessages(Message.newBuilder().setBody(Any.getDefaultInstance())) + .build(); + RecordingSink sink = new RecordingSink(); + GeneratedPayloadLimitValidator.dispatch(sink, req); + assertEquals(Collections.singletonList("messages[0].body"), sink.sorted()); + } + + // --- NOT_VALIDATED fields must never be checked --------------------------- + // + // These are the tests that catch an accidental reclassification: a field the server does not + // size-check (or checks in a way the SDK cannot replicate) must produce no callback at all, so + // asserting the exact visited set is stronger than asserting no violation. + + @Test + public void startWorkflowVisitsOnlyItsValidatedFields() { + StartWorkflowExecutionRequest req = + StartWorkflowExecutionRequest.newBuilder() + .setInput(payloads(1)) // blob + .setMemo(memoWith("k", 1)) // memo + .setHeader(Header.newBuilder().putFields("h", payload(1))) // metric only + .setSearchAttributes( + SearchAttributes.newBuilder().putIndexedFields("sa", payload(1))) // server-state + .setLastCompletionResult(payloads(1)) // carry-over, not checked here + .setContinuedFailure(Failure.newBuilder().setMessage("boom")) // carry-over + .setUserMetadata( + UserMetadata.newBuilder() + .setSummary(payload(1)) + .setDetails(payload(1))) // dedicated non-fetchable limits + .build(); + RecordingSink sink = new RecordingSink(); + GeneratedPayloadLimitValidator.dispatch(sink, req); + assertEquals(Arrays.asList("input", "memo"), sink.sorted()); + } + + @Test + public void continueAsNewVisitsOnlyItsValidatedFields() { + RespondWorkflowTaskCompletedRequest req = + wftWithCommand( + Command.newBuilder() + .setContinueAsNewWorkflowExecutionCommandAttributes( + ContinueAsNewWorkflowExecutionCommandAttributes.newBuilder() + .setInput(payloads(1)) // blob + .setMemo(memoWith("k", 1)) // memo + .setHeader(Header.newBuilder().putFields("h", payload(1))) + .setSearchAttributes( + SearchAttributes.newBuilder().putIndexedFields("sa", payload(1))) + .setFailure(Failure.newBuilder().setMessage("boom")) + .setLastCompletionResult(payloads(1))) + .build()); + RecordingSink sink = new RecordingSink(); + GeneratedPayloadLimitValidator.dispatch(sink, req); + assertEquals( + Arrays.asList( + "commands[0].continue_as_new_workflow_execution_command_attributes.input", + "commands[0].continue_as_new_workflow_execution_command_attributes.memo"), + sink.sorted()); + } + + @Test + public void recordMarkerVisitsOnlyDetails() { + RespondWorkflowTaskCompletedRequest req = + wftWithCommand( + Command.newBuilder() + .setRecordMarkerCommandAttributes( + RecordMarkerCommandAttributes.newBuilder() + .putDetails("marker", payloads(1)) // blob + .setHeader(Header.newBuilder().putFields("h", payload(1))) + .setFailure(Failure.newBuilder().setMessage("boom"))) + .build()); + RecordingSink sink = new RecordingSink(); + GeneratedPayloadLimitValidator.dispatch(sink, req); + assertEquals( + Collections.singletonList("commands[0].record_marker_command_attributes.details"), + sink.sorted()); + } + + @Test + public void requestsWithOnlyNotValidatedFieldsProduceNoChecks() { + RecordingSink terminate = new RecordingSink(); + GeneratedPayloadLimitValidator.dispatch( + terminate, + TerminateWorkflowExecutionRequest.newBuilder().setDetails(payloads(5000)).build()); + assertEquals(Collections.emptyList(), terminate.sorted()); + + // Update args are recorded as a metric by the frontend; the size the server enforces is the + // protocol Message body on delivery, which is checked on the worker's completion instead. + RecordingSink update = new RecordingSink(); + GeneratedPayloadLimitValidator.dispatch( + update, + UpdateWorkflowExecutionRequest.newBuilder() + .setRequest( + Request.newBuilder() + .setInput( + Input.newBuilder() + .setArgs(payloads(5000)) + .setHeader(Header.newBuilder().putFields("h", payload(1))))) + .build()); + assertEquals(Collections.emptyList(), update.sorted()); + } + + @Test + public void mapKeyedFieldsRenderTheKeyInThePath() { + RespondWorkflowTaskCompletedRequest req = + RespondWorkflowTaskCompletedRequest.newBuilder() + .putQueryResults( + "query-id", WorkflowQueryResult.newBuilder().setAnswer(payloads(1)).build()) + .build(); + RecordingSink sink = new RecordingSink(); + GeneratedPayloadLimitValidator.dispatch(sink, req); + assertEquals(Collections.singletonList("query_results[query-id].answer"), sink.sorted()); + } + + // --- size helpers --------------------------------------------------------- + + @Test + public void mapPayloadDataSumCountsUtf8KeyBytesAndRawData() { + // The server sums len(key) + len(payload.data) over Go strings, i.e. UTF-8 bytes: the 2-char + // key below is 6 bytes, so a char count would under-measure it. + Map fields = new HashMap<>(); + fields.put("\u00e9\u4e2d", payload(10)); // 2 + 3 UTF-8 bytes + assertEquals(15, PayloadLimitSizes.mapPayloadDataSum(fields)); + } + + private static String repeat(String s, int n) { + StringBuilder sb = new StringBuilder(s.length() * n); + for (int i = 0; i < n; i++) { + sb.append(s); + } + return sb.toString(); + } + + /** A sink that records the path of each visited field (order is not significant). */ + private static final class RecordingSink implements PayloadLimitSink { + private final PayloadPath path = new PayloadPath(); + private final List visited = new ArrayList<>(); + + @Override + public void check(String fieldName, LimitClass limitClass, long size, boolean enforceError) { + visited.add(path.leaf(fieldName)); + } + + @Override + public void enter(String name) { + path.push(name); + } + + @Override + public void enter(String name, int index) { + path.push(name, index); + } + + @Override + public void enter(String name, String key) { + path.push(name, key); + } + + @Override + public void exit() { + path.pop(); + } + + List sorted() { + List v = new ArrayList<>(visited); + Collections.sort(v); + return v; + } + } +}