diff --git a/README.md b/README.md index f3f76aaf..1d1d2bc0 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,11 @@ These samples demonstrate various capabilities of Java Cadence client and server * **Custom Workflow Controls** ([`com.uber.cadence.samples.query`](src/main/java/com/uber/cadence/samples/query/)) — workflow queries that return **markdown** for Cadence Web (Markdoc buttons that **signal** workflows or **start** new workflows). **Requires Cadence Web v4.0.14+.** Copy-paste run instructions: [query samples README](src/main/java/com/uber/cadence/samples/query/README.md). +* **DataConverter Samples** — three independent custom `DataConverter` patterns that transparently transform every workflow input, output, and activity parameter. Each lives in its own package and is fully standalone, so you can copy any one of them into your own project: + * **Compression** ([`com.uber.cadence.samples.compression`](src/main/java/com/uber/cadence/samples/compression/)) — gzip-over-JSON; typically 60-80% size reduction for repetitive payloads. [README](src/main/java/com/uber/cadence/samples/compression/README.md). + * **Encryption** ([`com.uber.cadence.samples.encryption`](src/main/java/com/uber/cadence/samples/encryption/)) — AES-256-GCM so payloads in Cadence history are unreadable without the key. [README](src/main/java/com/uber/cadence/samples/encryption/README.md). + * **Claim-check offload** ([`com.uber.cadence.samples.claimcheck`](src/main/java/com/uber/cadence/samples/claimcheck/)) — payloads above a threshold are stored in an external `BlobStore` (S3, GCS, Azure Blob, MinIO, local disk); only a small reference travels through history. [README](src/main/java/com/uber/cadence/samples/claimcheck/README.md). + ## Get the Samples Run the following commands: @@ -139,6 +144,31 @@ Starters (pick one per run): In Cadence Web, open the workflow → **Query** tab → run query **`Signal`**, **`options`**, or **`dashboard`** (matching the starter you used). +### DataConverter Samples + +Three independent samples demonstrating custom `DataConverter` implementations. Each sample is self-contained in its own package with its own worker, starter, task list, and README. Pick one to run, or run all three in parallel — they share nothing. + +#### Compression (gzip-over-JSON) + +See [src/main/java/com/uber/cadence/samples/compression/README.md](src/main/java/com/uber/cadence/samples/compression/README.md). + + ./gradlew -q execute -PmainClass=com.uber.cadence.samples.compression.CompressionWorker + ./gradlew -q execute -PmainClass=com.uber.cadence.samples.compression.CompressionStarter + +#### Encryption (AES-256-GCM) + +See [src/main/java/com/uber/cadence/samples/encryption/README.md](src/main/java/com/uber/cadence/samples/encryption/README.md) for the `CADENCE_ENCRYPTION_KEY` env var. + + ./gradlew -q execute -PmainClass=com.uber.cadence.samples.encryption.EncryptionWorker + ./gradlew -q execute -PmainClass=com.uber.cadence.samples.encryption.EncryptionStarter + +#### Claim-check offload + +See [src/main/java/com/uber/cadence/samples/claimcheck/README.md](src/main/java/com/uber/cadence/samples/claimcheck/README.md) for swap-in instructions for S3, GCS, Azure Blob, and MinIO. + + ./gradlew -q execute -PmainClass=com.uber.cadence.samples.claimcheck.ClaimCheckWorker + ./gradlew -q execute -PmainClass=com.uber.cadence.samples.claimcheck.ClaimCheckStarter + ### Trip Booking Cadence implementation of the [Camunda BPMN trip booking example](https://github.com/berndruecker/trip-booking-saga-java) diff --git a/src/main/java/com/uber/cadence/samples/claimcheck/BlobStore.java b/src/main/java/com/uber/cadence/samples/claimcheck/BlobStore.java new file mode 100644 index 00000000..4c6e90fa --- /dev/null +++ b/src/main/java/com/uber/cadence/samples/claimcheck/BlobStore.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Modifications copyright (C) 2017 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not + * use this file except in compliance with the License. A copy of the License is + * located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package com.uber.cadence.samples.claimcheck; + +import java.io.IOException; + +/** + * Abstraction over any external object store (local filesystem, S3, GCS, Azure Blob, etc.). + * + *
{@link ClaimCheckDataConverter} uses this interface to store large payloads outside Cadence + * history. The default implementation is {@link LocalFsBlobStore}, which writes to the system + * temporary directory and requires no external services. + */ +public interface BlobStore { + + /** Stores {@code data} under {@code key}, overwriting any existing value. */ + void put(String key, byte[] data) throws IOException; + + /** Returns the bytes previously stored under {@code key}. */ + byte[] get(String key) throws IOException; +} diff --git a/src/main/java/com/uber/cadence/samples/claimcheck/ClaimCheckDataConverter.java b/src/main/java/com/uber/cadence/samples/claimcheck/ClaimCheckDataConverter.java new file mode 100644 index 00000000..3256bffc --- /dev/null +++ b/src/main/java/com/uber/cadence/samples/claimcheck/ClaimCheckDataConverter.java @@ -0,0 +1,235 @@ +/* + * Copyright 2012-2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Modifications copyright (C) 2017 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not + * use this file except in compliance with the License. A copy of the License is + * located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package com.uber.cadence.samples.claimcheck; + +import com.uber.cadence.converter.DataConverter; +import com.uber.cadence.converter.DataConverterException; +import com.uber.cadence.converter.JsonDataConverter; +import java.io.IOException; +import java.lang.reflect.Type; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * {@link DataConverter} that implements the claim-check pattern: payloads larger than + * {@code thresholdBytes} are stored in an external {@link BlobStore} and only a small reference + * travels through Cadence workflow history. + * + *
This solves the practical problem of Cadence's per-payload size limits (~2 MB) for workflows + * that must pass very large datasets between the workflow and its activities, and reduces history + * storage cost for long-running workflows that pass large repeatable data. + * + *
Wire format (after the JSON delegate produces the bytes): + * + *
Keys are derived from the SHA-256 of the payload so {@code toData} is idempotent across
+ * Cadence workflow replays. Using a fresh UUID per call would write a new orphaned blob on every
+ * replay because the SDK calls {@code toData} again each time the workflow re-executes from the
+ * top. If the workflow needs to control the key (e.g. to encode routing metadata), generate it with
+ * {@code Workflow.sideEffect} and pass it alongside the payload instead.
+ */
+/*
+ * =============================================================================
+ * Swapping LocalFsBlobStore for a real object store
+ *
+ * The DataConverter is storage-agnostic: any class that implements `BlobStore` (two methods, `put`
+ * and `get`) will work. Swap `new LocalFsBlobStore()` in ClaimCheckWorker for your own impl and the
+ * workflow/activity code stays the same. Backend pointers:
+ *
+ * - AWS S3: software.amazon.awssdk:s3:2.25.0 (S3Client + PutObjectRequest/GetObjectRequest)
+ * - GCS: com.google.cloud:google-cloud-storage (Storage.create(blobInfo, bytes))
+ * - Azure Blob: com.azure:azure-storage-blob (BlobContainerClient.getBlobClient(...))
+ * - MinIO / R2 /
+ * LocalStack: same as S3, just call S3Client.builder().endpointOverride(URI.create("..."))
+ *
+ * Reference S3 sketch using AWS SDK v2:
+ *
+ * public final class S3BlobStore implements BlobStore {
+ * private final S3Client s3;
+ * private final String bucket;
+ *
+ * public S3BlobStore(String bucket, String region) {
+ * this.s3 = S3Client.builder().region(Region.of(region)).build();
+ * this.bucket = bucket;
+ * }
+ *
+ * public void put(String key, byte[] data) {
+ * s3.putObject(
+ * PutObjectRequest.builder().bucket(bucket).key(key).build(),
+ * RequestBody.fromBytes(data));
+ * }
+ *
+ * public byte[] get(String key) {
+ * return s3.getObjectAsBytes(GetObjectRequest.builder().bucket(bucket).key(key).build())
+ * .asByteArray();
+ * }
+ * }
+ *
+ * Wiring steps for any backend:
+ * 1. Add the backend's SDK as a runtime dependency in build.gradle.
+ * 2. Implement BlobStore against that SDK (≈30 lines, like the sketch above).
+ * 3. Replace `new LocalFsBlobStore()` with your `BlobStore` impl in ClaimCheckWorker.
+ * 4. Provide credentials via the SDK's standard mechanism (env vars, IAM role, etc.).
+ *
+ * Note on cleanup: this DataConverter does not delete blobs after the workflow completes. In
+ * production, use the object store's lifecycle policies (S3 object lifecycle, GCS object lifecycle
+ * management, Azure Blob lifecycle management, etc.) to automatically expire old blobs.
+ * =============================================================================
+ */
+public final class ClaimCheckDataConverter implements DataConverter {
+
+ /** Prefix byte for inline (below-threshold) payloads. */
+ static final byte INLINE_PREFIX = (byte) 0x00;
+
+ /** Prefix byte for offloaded payloads. */
+ static final byte OFFLOAD_PREFIX = (byte) 0x01;
+
+ private static final DataConverter delegate = JsonDataConverter.getInstance();
+
+ private final BlobStore store;
+ private final String bucket;
+ private final int thresholdBytes;
+
+ static final class BlobReference {
+ public String blobRef;
+
+ public BlobReference() {}
+
+ BlobReference(String blobRef) {
+ this.blobRef = blobRef;
+ }
+ }
+
+ /**
+ * @param store the BlobStore backend (use {@link LocalFsBlobStore} for zero-config demo).
+ * @param bucket logical bucket / prefix name embedded in the reference key.
+ * @param thresholdBytes max inline payload size; larger payloads are offloaded.
+ */
+ public ClaimCheckDataConverter(BlobStore store, String bucket, int thresholdBytes) {
+ if (store == null) {
+ throw new IllegalArgumentException("store must not be null");
+ }
+ if (bucket == null || bucket.trim().isEmpty()) {
+ throw new IllegalArgumentException("bucket must not be null or empty");
+ }
+ if (thresholdBytes < 0) {
+ throw new IllegalArgumentException("thresholdBytes must not be negative");
+ }
+ this.store = store;
+ this.bucket = bucket;
+ this.thresholdBytes = thresholdBytes;
+ }
+
+ @Override
+ public byte[] toData(Object... values) throws DataConverterException {
+ if (values == null || values.length == 0) {
+ return null;
+ }
+ byte[] jsonBytes = delegate.toData(values);
+ if (jsonBytes == null || jsonBytes.length == 0) {
+ return jsonBytes;
+ }
+
+ if (jsonBytes.length <= thresholdBytes) {
+ byte[] result = new byte[1 + jsonBytes.length];
+ result[0] = INLINE_PREFIX;
+ System.arraycopy(jsonBytes, 0, result, 1, jsonBytes.length);
+ return result;
+ }
+
+ String key = bucket + "/" + sha256Hex(jsonBytes);
+ try {
+ store.put(key, jsonBytes);
+ } catch (IOException e) {
+ throw new DataConverterException(
+ "Failed to offload payload to blob store (key=" + key + ")", e);
+ }
+
+ byte[] envBytes = delegate.toData(new BlobReference(key));
+ byte[] result = new byte[1 + envBytes.length];
+ result[0] = OFFLOAD_PREFIX;
+ System.arraycopy(envBytes, 0, result, 1, envBytes.length);
+ return result;
+ }
+
+ @Override
+ public The workflow takes no inputs and builds a payload well above the threshold internally so it
+ * can be started from the Cadence CLI and every run exercises the offload path.
+ */
+public final class ClaimCheckDataConverterWorkflow {
+
+ private ClaimCheckDataConverterWorkflow() {}
+
+ /** Task list polled by {@link ClaimCheckWorker}. */
+ public static final String TASK_LIST = "data-claimcheck";
+
+ /**
+ * Registered workflow type, used for both {@code @WorkflowMethod} and CLI {@code workflow start}.
+ */
+ public static final String WORKFLOW_TYPE = "ClaimCheckDataConverterWorkflow";
+
+ /** Logical bucket / prefix embedded in claim-check reference keys. */
+ public static final String BLOB_BUCKET = "claimcheck-blobs";
+
+ /**
+ * Payloads larger than this are offloaded to the BlobStore by {@link ClaimCheckDataConverter}.
+ * Cadence's default max payload is roughly 2 MB; the threshold is set intentionally low so the
+ * demo workflow comfortably triggers offloading.
+ */
+ public static final int DEFAULT_THRESHOLD_BYTES = 4096;
+
+ // ---------------- POJOs ----------------
+
+ public static final class LargePayload {
+ public String jobId;
+ public String description;
+ public List The workflow takes no inputs and generates its own payload, so this starter does not need to
+ * use the matching {@link ClaimCheckDataConverter}. The same effect can be achieved from the
+ * Cadence CLI via:
+ *
+ * The default zero-config implementation used by {@link ClaimCheckDataConverter} when running
+ * the demo without a real object store. Files are written under {@code
+ * ${java.io.tmpdir}/cadence-java-samples-claimcheck/}.
+ */
+public final class LocalFsBlobStore implements BlobStore {
+
+ private final Path baseDir;
+
+ public LocalFsBlobStore() {
+ this(Paths.get(System.getProperty("java.io.tmpdir"), "cadence-java-samples-claimcheck"));
+ }
+
+ public LocalFsBlobStore(Path baseDir) {
+ if (baseDir == null) {
+ throw new IllegalArgumentException("baseDir must not be null");
+ }
+ this.baseDir = baseDir.toAbsolutePath().normalize();
+ try {
+ Files.createDirectories(this.baseDir);
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to create blob store dir " + this.baseDir, e);
+ }
+ }
+
+ /** Returns the directory the store writes to (useful for stats banners). */
+ public Path baseDir() {
+ return baseDir;
+ }
+
+ @Override
+ public void put(String key, byte[] data) throws IOException {
+ Files.write(baseDir.resolve(filenameForKey(key)), data);
+ }
+
+ @Override
+ public byte[] get(String key) throws IOException {
+ return Files.readAllBytes(baseDir.resolve(filenameForKey(key)));
+ }
+
+ /**
+ * Turns any blob-store key into a fixed safe filename. Keys are usually generated internally by
+ * the DataConverter, but hashing prevents directory traversal even if a future caller passes a
+ * user-controlled key.
+ */
+ private static String filenameForKey(String key) throws IOException {
+ if (key == null || key.isEmpty()) {
+ throw new IOException("BlobStore key must not be null or empty");
+ }
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(key.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(digest.length * 2);
+ for (byte b : digest) {
+ sb.append(String.format("%02x", b & 0xff));
+ }
+ return sb.toString();
+ } catch (NoSuchAlgorithmException e) {
+ throw new IOException("SHA-256 is not available in this JVM", e);
+ }
+ }
+}
diff --git a/src/main/java/com/uber/cadence/samples/claimcheck/README.md b/src/main/java/com/uber/cadence/samples/claimcheck/README.md
new file mode 100644
index 00000000..ae46e9de
--- /dev/null
+++ b/src/main/java/com/uber/cadence/samples/claimcheck/README.md
@@ -0,0 +1,81 @@
+# Claim-Check DataConverter Sample
+
+A custom Cadence [`DataConverter`](../../../../../../../../README.md) that implements the **[claim-check pattern](https://www.enterpriseintegrationpatterns.com/patterns/messaging/StoreInLibrary.html)**: payloads larger than a configurable threshold are stored in an external `BlobStore` (S3, GCS, Azure Blob, MinIO, local disk, etc.) and only a small reference travels through Cadence workflow history.
+
+This solves Cadence's per-payload size limits (~2 MB) for workflows that pass very large datasets, and lowers history storage cost for long-running workflows that pass large repeatable data.
+
+- **Task list:** `data-claimcheck`
+- **Workflow type:** `ClaimCheckDataConverterWorkflow`
+- **Default threshold:** 4 KB (deliberately low so the demo always offloads)
+- **Default backing store:** [`LocalFsBlobStore`](LocalFsBlobStore.java) writing to `${java.io.tmpdir}/cadence-java-samples-claimcheck/`
+
+## Prerequisites
+
+1. Cadence server running (e.g. Docker Compose from the [Cadence repo](https://github.com/uber/cadence)).
+2. From the repo root, build: `./gradlew build`.
+
+### Register the domain (required once per cluster)
+
+```bash
+./gradlew -q execute -PmainClass=com.uber.cadence.samples.common.RegisterDomain
+```
+
+Or with the Cadence CLI:
+
+```bash
+cadence --domain samples-domain domain register
+```
+
+## Run the worker (terminal 1)
+
+The worker prints a claim-check statistics banner showing how much was offloaded to the blob store vs how little ends up in Cadence history, then begins polling the `data-claimcheck` task list:
+
+```bash
+./gradlew -q execute -PmainClass=com.uber.cadence.samples.claimcheck.ClaimCheckWorker
+```
+
+## Start a workflow (terminal 2)
+
+```bash
+./gradlew -q execute -PmainClass=com.uber.cadence.samples.claimcheck.ClaimCheckStarter
+```
+
+Or from the Cadence CLI:
+
+```bash
+cadence --domain samples-domain \
+ workflow start \
+ --workflow_type ClaimCheckDataConverterWorkflow \
+ --tl data-claimcheck \
+ --et 60
+```
+
+## How it works
+
+- `toData`: JSON-encode the arguments with the standard `JsonDataConverter`. If the resulting bytes are at or below the threshold, write `0x00 || json` and return inline. Otherwise compute a SHA-256 of the bytes, `PUT` to the blob store under ` The workflow takes no inputs and builds its own large payload internally so it can be started
+ * from the Cadence CLI without bundling a custom converter into the caller.
+ */
+public final class CompressedDataConverterWorkflow {
+
+ private CompressedDataConverterWorkflow() {}
+
+ /** Task list polled by {@link CompressionWorker}. */
+ public static final String TASK_LIST = "data-compression";
+
+ /**
+ * Registered workflow type, used for both {@code @WorkflowMethod} and CLI {@code workflow start}.
+ */
+ public static final String WORKFLOW_TYPE = "CompressedDataConverterWorkflow";
+
+ // ---------------- POJOs ----------------
+
+ /**
+ * A complex data structure with nested objects and arrays designed to demonstrate compression
+ * benefits. Fields are public + have no-arg constructors so the JSON data converter can serialize
+ * and deserialize them.
+ */
+ public static final class LargePayload {
+ public String id;
+ public String name;
+ public String description;
+ public Map For repetitive JSON payloads this typically achieves 60-80% size reduction, lowering storage
+ * cost and bandwidth without changing any workflow or activity code. Apply by setting it on the
+ * {@code WorkflowClientOptions} used by both the worker and any client that triggers the workflow.
+ * The decode path caps decompressed payloads to avoid unbounded memory growth on malformed input.
+ */
+public final class CompressedJsonDataConverter implements DataConverter {
+
+ /** Production code should choose a limit appropriate for its workflow payload contract. */
+ public static final int DEFAULT_MAX_DECOMPRESSED_BYTES = 10 * 1024 * 1024;
+
+ private static final DataConverter delegate = JsonDataConverter.getInstance();
+
+ private final int maxDecompressedBytes;
+
+ public CompressedJsonDataConverter() {
+ this(DEFAULT_MAX_DECOMPRESSED_BYTES);
+ }
+
+ public CompressedJsonDataConverter(int maxDecompressedBytes) {
+ if (maxDecompressedBytes <= 0) {
+ throw new IllegalArgumentException("maxDecompressedBytes must be positive");
+ }
+ this.maxDecompressedBytes = maxDecompressedBytes;
+ }
+
+ @Override
+ public byte[] toData(Object... values) throws DataConverterException {
+ if (values == null || values.length == 0) {
+ return null;
+ }
+ byte[] jsonBytes = delegate.toData(values);
+ if (jsonBytes == null || jsonBytes.length == 0) {
+ return jsonBytes;
+ }
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (GZIPOutputStream gzip = new GZIPOutputStream(out)) {
+ gzip.write(jsonBytes);
+ } catch (IOException e) {
+ throw new DataConverterException("Failed to gzip-compress JSON payload", e);
+ }
+ return out.toByteArray();
+ }
+
+ @Override
+ public The workflow takes no inputs and generates its own payload, so this starter does not need to
+ * use the matching {@link CompressedJsonDataConverter}. The same effect can be achieved from the
+ * Cadence CLI via:
+ *
+ * The workflow takes no inputs and builds its own sensitive payload internally so it can be
+ * started from the Cadence CLI without bundling the encryption key into the caller.
+ */
+public final class EncryptedDataConverterWorkflow {
+
+ private EncryptedDataConverterWorkflow() {}
+
+ /** Task list polled by {@link EncryptionWorker}. */
+ public static final String TASK_LIST = "data-encryption";
+
+ /**
+ * Registered workflow type, used for both {@code @WorkflowMethod} and CLI {@code workflow start}.
+ */
+ public static final String WORKFLOW_TYPE = "EncryptedDataConverterWorkflow";
+
+ // ---------------- POJOs ----------------
+
+ /** PII / PHI-style record that must be encrypted in workflow history. */
+ public static final class SensitiveCustomerRecord {
+ public String customerId;
+ public String fullName;
+ public String email;
+ public String ssn;
+ public String creditCardNumber;
+ public String billingAddress;
+ public String medicalNotes;
+ public String diagnosisCode;
+ public String prescriptions;
+ public String insuranceId;
+ public String processedBy;
+
+ public SensitiveCustomerRecord() {}
+ }
+
+ /** Builds a sample customer record with realistic-looking PII and PHI fields. */
+ public static SensitiveCustomerRecord createSensitiveCustomerRecord() {
+ SensitiveCustomerRecord r = new SensitiveCustomerRecord();
+ r.customerId = "cust_8a7f3b2e";
+ r.fullName = "Jane A. Doe";
+ r.email = "jane.doe@example.com";
+ r.ssn = "123-45-6789";
+ r.creditCardNumber = "4111-1111-1111-1111";
+ r.billingAddress = "1234 Elm Street, Springfield, IL 62701";
+ r.medicalNotes =
+ "Patient presents with hypertension and type-2 diabetes. Advised dietary changes and "
+ + "increased physical activity. Follow-up scheduled in 3 months.";
+ r.diagnosisCode = "I10, E11.9";
+ r.prescriptions = "Lisinopril 10mg once daily; Metformin 500mg twice daily";
+ r.insuranceId = "INS-987654321";
+ r.processedBy = "workflow-processor-v2";
+ return r;
+ }
+
+ // ---------------- Workflow + activity ----------------
+
+ public interface WorkflowIface {
+
+ @WorkflowMethod(
+ name = WORKFLOW_TYPE,
+ executionStartToCloseTimeoutSeconds = 60,
+ taskList = TASK_LIST
+ )
+ SensitiveCustomerRecord run();
+ }
+
+ public interface Activities {
+
+ @ActivityMethod(scheduleToCloseTimeoutSeconds = 60)
+ SensitiveCustomerRecord processCustomerRecord(SensitiveCustomerRecord record);
+ }
+
+ public static final class WorkflowImpl implements WorkflowIface {
+
+ private final Activities activities =
+ Workflow.newActivityStub(
+ Activities.class,
+ new ActivityOptions.Builder()
+ .setScheduleToStartTimeout(Duration.ofMinutes(1))
+ .setStartToCloseTimeout(Duration.ofMinutes(1))
+ .build());
+
+ @Override
+ public SensitiveCustomerRecord run() {
+ SensitiveCustomerRecord record = createSensitiveCustomerRecord();
+
+ Workflow.getLogger(EncryptedDataConverterWorkflow.class)
+ .info(
+ "Encryption workflow started: customer_id={}. All PII/PHI will be encrypted before storage.",
+ record.customerId);
+
+ SensitiveCustomerRecord result = activities.processCustomerRecord(record);
+
+ Workflow.getLogger(EncryptedDataConverterWorkflow.class)
+ .info(
+ "Encryption workflow completed: customer_id={}. PII/PHI was automatically AES-256-GCM encrypted/decrypted.",
+ result.customerId);
+ return result;
+ }
+ }
+
+ public static final class ActivitiesImpl implements Activities {
+
+ @Override
+ public SensitiveCustomerRecord processCustomerRecord(SensitiveCustomerRecord record) {
+ record.processedBy = record.processedBy + " (Encrypted)";
+ return record;
+ }
+ }
+}
diff --git a/src/main/java/com/uber/cadence/samples/encryption/EncryptedJsonDataConverter.java b/src/main/java/com/uber/cadence/samples/encryption/EncryptedJsonDataConverter.java
new file mode 100644
index 00000000..e0d53fc2
--- /dev/null
+++ b/src/main/java/com/uber/cadence/samples/encryption/EncryptedJsonDataConverter.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2012-2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Modifications copyright (C) 2017 Uber Technologies, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"). You may not
+ * use this file except in compliance with the License. A copy of the License is
+ * located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package com.uber.cadence.samples.encryption;
+
+import com.uber.cadence.converter.DataConverter;
+import com.uber.cadence.converter.DataConverterException;
+import com.uber.cadence.converter.JsonDataConverter;
+import java.lang.reflect.Type;
+import java.security.GeneralSecurityException;
+import java.security.SecureRandom;
+import javax.crypto.Cipher;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * {@link DataConverter} that JSON-encodes via {@link JsonDataConverter} and then encrypts with
+ * AES-256-GCM.
+ *
+ * Every workflow input, output, and activity parameter is encrypted before being written to
+ * Cadence history. Without the key, payloads stored by the Cadence server are unreadable to
+ * operators browsing workflow history. Logs, metrics, and search attributes are separate disclosure
+ * surfaces and must be handled separately.
+ *
+ * Output layout: {@code nonce(12 bytes) || ciphertext || tag(16 bytes)}. The random nonce means
+ * the same plaintext produces different ciphertext on every call, which preserves semantic security
+ * for repeated payloads. The GCM authentication tag ensures any ciphertext tampering is detected at
+ * decode time.
+ */
+public final class EncryptedJsonDataConverter implements DataConverter {
+
+ private static final DataConverter delegate = JsonDataConverter.getInstance();
+ private static final String TRANSFORM = "AES/GCM/NoPadding";
+ private static final int NONCE_BYTES = 12;
+ private static final int TAG_BITS = 128;
+
+ private final SecretKeySpec key;
+ private final SecureRandom random = new SecureRandom();
+
+ /**
+ * @param keyBytes 32-byte AES-256 key. The caller is responsible for sourcing this from a secrets
+ * manager in production; see {@link EncryptionKeyLoader}.
+ * @throws IllegalArgumentException if the key is not 32 bytes.
+ */
+ public EncryptedJsonDataConverter(byte[] keyBytes) {
+ if (keyBytes == null || keyBytes.length != 32) {
+ throw new IllegalArgumentException(
+ "AES-256 key must be exactly 32 bytes, got " + (keyBytes == null ? 0 : keyBytes.length));
+ }
+ this.key = new SecretKeySpec(keyBytes, "AES");
+ }
+
+ @Override
+ public byte[] toData(Object... values) throws DataConverterException {
+ if (values == null || values.length == 0) {
+ return null;
+ }
+ byte[] jsonBytes = delegate.toData(values);
+ if (jsonBytes == null || jsonBytes.length == 0) {
+ return jsonBytes;
+ }
+ try {
+ byte[] nonce = new byte[NONCE_BYTES];
+ random.nextBytes(nonce);
+ Cipher cipher = Cipher.getInstance(TRANSFORM);
+ cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, nonce));
+ byte[] ciphertext = cipher.doFinal(jsonBytes);
+
+ byte[] out = new byte[NONCE_BYTES + ciphertext.length];
+ System.arraycopy(nonce, 0, out, 0, NONCE_BYTES);
+ System.arraycopy(ciphertext, 0, out, NONCE_BYTES, ciphertext.length);
+ return out;
+ } catch (GeneralSecurityException e) {
+ throw new DataConverterException("Failed to AES-256-GCM encrypt payload", e);
+ }
+ }
+
+ @Override
+ public Reads the key from the {@code CADENCE_ENCRYPTION_KEY} environment variable as 64 hex
+ * characters (32 bytes). If the env var is unset, falls back to a hardcoded demo key with a
+ * warning. If the env var is set but invalid, throws — silently falling back to the public demo key
+ * when the user clearly intended their own key would be a security hole.
+ */
+public final class EncryptionKeyLoader {
+
+ private EncryptionKeyLoader() {}
+
+ /** Hardcoded 32-byte key used ONLY when {@code CADENCE_ENCRYPTION_KEY} is unset. */
+ static final byte[] DEMO_ENCRYPTION_KEY =
+ "cadence-demo-key-NOT-FOR-PROD!!!".getBytes(StandardCharsets.US_ASCII);
+
+ /**
+ * Returns a 32-byte AES-256 key from {@code CADENCE_ENCRYPTION_KEY} or the demo key.
+ *
+ * @throws IllegalStateException if the env var is set but not valid hex or not 32 bytes long.
+ */
+ public static byte[] loadEncryptionKey() {
+ String hexKey = System.getenv("CADENCE_ENCRYPTION_KEY");
+ if (hexKey == null || hexKey.isEmpty()) {
+ System.out.println("WARNING: CADENCE_ENCRYPTION_KEY not set. Using hardcoded demo key.");
+ System.out.println("WARNING: DO NOT USE THE DEMO KEY IN PRODUCTION.");
+ return DEMO_ENCRYPTION_KEY.clone();
+ }
+ byte[] key;
+ try {
+ key = hexDecode(hexKey);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalStateException(
+ "CADENCE_ENCRYPTION_KEY is not valid hex: " + e.getMessage(), e);
+ }
+ if (key.length != 32) {
+ throw new IllegalStateException(
+ "CADENCE_ENCRYPTION_KEY must be exactly 64 hex chars (32 bytes), got "
+ + hexKey.length()
+ + " hex chars ("
+ + key.length
+ + " bytes)");
+ }
+ return key;
+ }
+
+ private static byte[] hexDecode(String s) {
+ int len = s.length();
+ if ((len & 1) != 0) {
+ throw new IllegalArgumentException("odd-length hex string");
+ }
+ byte[] out = new byte[len / 2];
+ for (int i = 0; i < len; i += 2) {
+ int hi = Character.digit(s.charAt(i), 16);
+ int lo = Character.digit(s.charAt(i + 1), 16);
+ if (hi < 0 || lo < 0) {
+ throw new IllegalArgumentException("non-hex character at offset " + i);
+ }
+ out[i / 2] = (byte) ((hi << 4) | lo);
+ }
+ return out;
+ }
+}
diff --git a/src/main/java/com/uber/cadence/samples/encryption/EncryptionStarter.java b/src/main/java/com/uber/cadence/samples/encryption/EncryptionStarter.java
new file mode 100644
index 00000000..3f713034
--- /dev/null
+++ b/src/main/java/com/uber/cadence/samples/encryption/EncryptionStarter.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2012-2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Modifications copyright (C) 2017 Uber Technologies, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"). You may not
+ * use this file except in compliance with the License. A copy of the License is
+ * located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package com.uber.cadence.samples.encryption;
+
+import com.uber.cadence.client.WorkflowClient;
+import com.uber.cadence.client.WorkflowClientOptions;
+import com.uber.cadence.client.WorkflowOptions;
+import com.uber.cadence.internal.compatibility.Thrift2ProtoAdapter;
+import com.uber.cadence.internal.compatibility.proto.serviceclient.IGrpcServiceStubs;
+import com.uber.cadence.samples.common.SampleConstants;
+import java.time.Duration;
+import java.util.UUID;
+
+/**
+ * Starts {@link EncryptedDataConverterWorkflow} (async, fire-and-forget).
+ *
+ * The workflow takes no inputs and generates its own payload, so this starter does not need the
+ * encryption key — the worker owns the key. The same effect can be achieved from the Cadence CLI
+ * via:
+ *
+ *
+ * cadence --domain samples-domain \
+ * workflow start \
+ * --workflow_type ClaimCheckDataConverterWorkflow \
+ * --tl data-claimcheck \
+ * --et 60
+ *
+ */
+public final class ClaimCheckStarter {
+
+ private ClaimCheckStarter() {}
+
+ public static void main(String[] args) {
+ try {
+ WorkflowClient client =
+ WorkflowClient.newInstance(
+ new Thrift2ProtoAdapter(IGrpcServiceStubs.newInstance()),
+ WorkflowClientOptions.newBuilder().setDomain(SampleConstants.DOMAIN).build());
+ WorkflowOptions options =
+ new WorkflowOptions.Builder()
+ .setTaskList(ClaimCheckDataConverterWorkflow.TASK_LIST)
+ .setExecutionStartToCloseTimeout(Duration.ofMinutes(1))
+ .setWorkflowId("claimcheck-" + UUID.randomUUID())
+ .build();
+
+ ClaimCheckDataConverterWorkflow.WorkflowIface workflow =
+ client.newWorkflowStub(ClaimCheckDataConverterWorkflow.WorkflowIface.class, options);
+
+ WorkflowClient.start(workflow::run);
+ System.out.println(
+ "Started "
+ + ClaimCheckDataConverterWorkflow.WORKFLOW_TYPE
+ + " on task list \""
+ + ClaimCheckDataConverterWorkflow.TASK_LIST
+ + "\".");
+ System.exit(0);
+ } catch (RuntimeException e) {
+ if (printHintIfDomainMissing(e)) {
+ System.exit(1);
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * Prints a copy-paste hint when the Cadence error indicates the sample domain has not been
+ * registered.
+ *
+ * @return true if {@code t} was a missing-domain error and a hint was printed (caller should
+ * exit).
+ */
+ static boolean printHintIfDomainMissing(Throwable t) {
+ for (Throwable c = t; c != null; c = c.getCause()) {
+ String m = c.getMessage();
+ if (m != null && m.contains("Domain") && m.contains("does not exist")) {
+ System.err.println();
+ System.err.println(
+ "Cadence reported that the domain \"" + SampleConstants.DOMAIN + "\" does not exist.");
+ System.err.println("Register it once against your cluster, then run this again:");
+ System.err.println();
+ System.err.println(
+ " ./gradlew -q execute -PmainClass=com.uber.cadence.samples.common.RegisterDomain");
+ System.err.println();
+ System.err.println("Or with Cadence CLI:");
+ System.err.println(" cadence --domain " + SampleConstants.DOMAIN + " domain register");
+ System.err.println();
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/com/uber/cadence/samples/claimcheck/ClaimCheckWorker.java b/src/main/java/com/uber/cadence/samples/claimcheck/ClaimCheckWorker.java
new file mode 100644
index 00000000..7df275f6
--- /dev/null
+++ b/src/main/java/com/uber/cadence/samples/claimcheck/ClaimCheckWorker.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2012-2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Modifications copyright (C) 2017 Uber Technologies, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"). You may not
+ * use this file except in compliance with the License. A copy of the License is
+ * located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package com.uber.cadence.samples.claimcheck;
+
+import com.uber.cadence.client.WorkflowClient;
+import com.uber.cadence.client.WorkflowClientOptions;
+import com.uber.cadence.converter.DataConverter;
+import com.uber.cadence.converter.JsonDataConverter;
+import com.uber.cadence.internal.compatibility.Thrift2ProtoAdapter;
+import com.uber.cadence.internal.compatibility.proto.serviceclient.IGrpcServiceStubs;
+import com.uber.cadence.samples.common.SampleConstants;
+import com.uber.cadence.worker.Worker;
+import com.uber.cadence.worker.WorkerFactory;
+
+/**
+ * Hosts the claim-check sample worker. Constructs a {@link WorkflowClient} configured with {@link
+ * ClaimCheckDataConverter} backed by {@link LocalFsBlobStore} so payloads above the threshold are
+ * stored on disk and replaced in Cadence history with a small reference. Swap in any real {@link
+ * BlobStore} (S3, GCS, Azure Blob, MinIO — see comments in {@link ClaimCheckDataConverter}) to move
+ * blobs to a remote object store without changing any workflow or activity code.
+ */
+public final class ClaimCheckWorker {
+
+ private ClaimCheckWorker() {}
+
+ public static void main(String[] args) {
+ LocalFsBlobStore blobStore = new LocalFsBlobStore();
+ DataConverter converter =
+ new ClaimCheckDataConverter(
+ blobStore,
+ ClaimCheckDataConverterWorkflow.BLOB_BUCKET,
+ ClaimCheckDataConverterWorkflow.DEFAULT_THRESHOLD_BYTES);
+ WorkflowClient client =
+ WorkflowClient.newInstance(
+ new Thrift2ProtoAdapter(IGrpcServiceStubs.newInstance()),
+ WorkflowClientOptions.newBuilder()
+ .setDomain(SampleConstants.DOMAIN)
+ .setDataConverter(converter)
+ .build());
+
+ WorkerFactory factory = WorkerFactory.newInstance(client);
+ Worker worker = factory.newWorker(ClaimCheckDataConverterWorkflow.TASK_LIST);
+ worker.registerWorkflowImplementationTypes(ClaimCheckDataConverterWorkflow.WorkflowImpl.class);
+ worker.registerActivitiesImplementations(new ClaimCheckDataConverterWorkflow.ActivitiesImpl());
+ factory.start();
+
+ printClaimCheckStats(blobStore);
+
+ System.out.println(
+ "ClaimCheckWorker listening on \""
+ + ClaimCheckDataConverterWorkflow.TASK_LIST
+ + "\" (domain \""
+ + SampleConstants.DOMAIN
+ + "\").");
+
+ Runtime.getRuntime().addShutdownHook(new Thread(factory::shutdown));
+ }
+
+ private static void printClaimCheckStats(LocalFsBlobStore store) {
+ ClaimCheckDataConverterWorkflow.LargePayload payload =
+ ClaimCheckDataConverterWorkflow.createLargePayload();
+ byte[] jsonBytes = JsonDataConverter.getInstance().toData(payload);
+ int jsonSize = jsonBytes == null ? 0 : jsonBytes.length;
+ // History footprint = 1 prefix byte + JSON envelope {"blobRef":"
+ * cadence --domain samples-domain \
+ * workflow start \
+ * --workflow_type CompressedDataConverterWorkflow \
+ * --tl data-compression \
+ * --et 60
+ *
+ */
+public final class CompressionStarter {
+
+ private CompressionStarter() {}
+
+ public static void main(String[] args) {
+ try {
+ WorkflowClient client =
+ WorkflowClient.newInstance(
+ new Thrift2ProtoAdapter(IGrpcServiceStubs.newInstance()),
+ WorkflowClientOptions.newBuilder().setDomain(SampleConstants.DOMAIN).build());
+ WorkflowOptions options =
+ new WorkflowOptions.Builder()
+ .setTaskList(CompressedDataConverterWorkflow.TASK_LIST)
+ .setExecutionStartToCloseTimeout(Duration.ofMinutes(1))
+ .setWorkflowId("compression-" + UUID.randomUUID())
+ .build();
+
+ CompressedDataConverterWorkflow.WorkflowIface workflow =
+ client.newWorkflowStub(CompressedDataConverterWorkflow.WorkflowIface.class, options);
+
+ WorkflowClient.start(workflow::run);
+ System.out.println(
+ "Started "
+ + CompressedDataConverterWorkflow.WORKFLOW_TYPE
+ + " on task list \""
+ + CompressedDataConverterWorkflow.TASK_LIST
+ + "\".");
+ System.exit(0);
+ } catch (RuntimeException e) {
+ if (printHintIfDomainMissing(e)) {
+ System.exit(1);
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * Prints a copy-paste hint when the Cadence error indicates the sample domain has not been
+ * registered.
+ *
+ * @return true if {@code t} was a missing-domain error and a hint was printed (caller should
+ * exit).
+ */
+ static boolean printHintIfDomainMissing(Throwable t) {
+ for (Throwable c = t; c != null; c = c.getCause()) {
+ String m = c.getMessage();
+ if (m != null && m.contains("Domain") && m.contains("does not exist")) {
+ System.err.println();
+ System.err.println(
+ "Cadence reported that the domain \"" + SampleConstants.DOMAIN + "\" does not exist.");
+ System.err.println("Register it once against your cluster, then run this again:");
+ System.err.println();
+ System.err.println(
+ " ./gradlew -q execute -PmainClass=com.uber.cadence.samples.common.RegisterDomain");
+ System.err.println();
+ System.err.println("Or with Cadence CLI:");
+ System.err.println(" cadence --domain " + SampleConstants.DOMAIN + " domain register");
+ System.err.println();
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/com/uber/cadence/samples/compression/CompressionWorker.java b/src/main/java/com/uber/cadence/samples/compression/CompressionWorker.java
new file mode 100644
index 00000000..fa3f0d61
--- /dev/null
+++ b/src/main/java/com/uber/cadence/samples/compression/CompressionWorker.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2012-2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Modifications copyright (C) 2017 Uber Technologies, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"). You may not
+ * use this file except in compliance with the License. A copy of the License is
+ * located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package com.uber.cadence.samples.compression;
+
+import com.uber.cadence.client.WorkflowClient;
+import com.uber.cadence.client.WorkflowClientOptions;
+import com.uber.cadence.converter.DataConverter;
+import com.uber.cadence.converter.JsonDataConverter;
+import com.uber.cadence.internal.compatibility.Thrift2ProtoAdapter;
+import com.uber.cadence.internal.compatibility.proto.serviceclient.IGrpcServiceStubs;
+import com.uber.cadence.samples.common.SampleConstants;
+import com.uber.cadence.worker.Worker;
+import com.uber.cadence.worker.WorkerFactory;
+
+/**
+ * Hosts the gzip-compression sample worker. Constructs a {@link WorkflowClient} configured with
+ * {@link CompressedJsonDataConverter} so every workflow input, output, and activity parameter is
+ * transparently gzip-compressed in Cadence history. On startup it prints a stats banner showing the
+ * before/after size of the sample payload so the benefit is visible at a glance.
+ */
+public final class CompressionWorker {
+
+ private CompressionWorker() {}
+
+ public static void main(String[] args) {
+ DataConverter converter = new CompressedJsonDataConverter();
+ WorkflowClient client =
+ WorkflowClient.newInstance(
+ new Thrift2ProtoAdapter(IGrpcServiceStubs.newInstance()),
+ WorkflowClientOptions.newBuilder()
+ .setDomain(SampleConstants.DOMAIN)
+ .setDataConverter(converter)
+ .build());
+
+ WorkerFactory factory = WorkerFactory.newInstance(client);
+ Worker worker = factory.newWorker(CompressedDataConverterWorkflow.TASK_LIST);
+ worker.registerWorkflowImplementationTypes(CompressedDataConverterWorkflow.WorkflowImpl.class);
+ worker.registerActivitiesImplementations(new CompressedDataConverterWorkflow.ActivitiesImpl());
+ factory.start();
+
+ printCompressionStats(converter);
+
+ System.out.println(
+ "CompressionWorker listening on \""
+ + CompressedDataConverterWorkflow.TASK_LIST
+ + "\" (domain \""
+ + SampleConstants.DOMAIN
+ + "\").");
+
+ Runtime.getRuntime().addShutdownHook(new Thread(factory::shutdown));
+ }
+
+ private static void printCompressionStats(DataConverter converter) {
+ CompressedDataConverterWorkflow.LargePayload payload =
+ CompressedDataConverterWorkflow.createLargePayload();
+ byte[] originalJson = JsonDataConverter.getInstance().toData(payload);
+ byte[] compressed = converter.toData(payload);
+ int originalSize = originalJson == null ? 0 : originalJson.length;
+ int compressedSize = compressed == null ? 0 : compressed.length;
+ double pct = originalSize == 0 ? 0.0 : (1.0 - (double) compressedSize / originalSize) * 100.0;
+
+ System.out.println();
+ System.out.println("=== Compression Sample Statistics ===");
+ System.out.printf(
+ "Original JSON size: %d bytes (%.2f KB)%n", originalSize, originalSize / 1024.0);
+ System.out.printf(
+ "Compressed size: %d bytes (%.2f KB)%n", compressedSize, compressedSize / 1024.0);
+ System.out.printf("Compression ratio: %.2f%% reduction%n", pct);
+ System.out.printf(
+ "Space saved: %d bytes (%.2f KB)%n",
+ originalSize - compressedSize, (originalSize - compressedSize) / 1024.0);
+ System.out.printf(
+ "Start workflow: cadence --domain %s workflow start --tl %s --workflow_type %s --et 60%n",
+ SampleConstants.DOMAIN,
+ CompressedDataConverterWorkflow.TASK_LIST,
+ CompressedDataConverterWorkflow.WORKFLOW_TYPE);
+ System.out.println("=====================================");
+ System.out.println();
+ }
+}
diff --git a/src/main/java/com/uber/cadence/samples/compression/README.md b/src/main/java/com/uber/cadence/samples/compression/README.md
new file mode 100644
index 00000000..46fac32a
--- /dev/null
+++ b/src/main/java/com/uber/cadence/samples/compression/README.md
@@ -0,0 +1,61 @@
+# Compression DataConverter Sample
+
+A custom Cadence [`DataConverter`](../../../../../../../../README.md) that JSON-encodes workflow data and then gzip-compresses the bytes. For repetitive JSON payloads this typically achieves 60-80% size reduction, lowering storage cost and bandwidth without changing any workflow or activity code. The decode path caps decompressed payloads (default 10 MB) so a malformed input cannot drive unbounded memory growth.
+
+- **Task list:** `data-compression`
+- **Workflow type:** `CompressedDataConverterWorkflow`
+
+## Prerequisites
+
+1. Cadence server running (e.g. Docker Compose from the [Cadence repo](https://github.com/uber/cadence)).
+2. From the repo root, build: `./gradlew build`.
+
+### Register the domain (required once per cluster)
+
+```bash
+./gradlew -q execute -PmainClass=com.uber.cadence.samples.common.RegisterDomain
+```
+
+Or with the Cadence CLI:
+
+```bash
+cadence --domain samples-domain domain register
+```
+
+## Run the worker (terminal 1)
+
+The worker prints a compression statistics banner showing the before/after sizes of the sample payload, then begins polling the `data-compression` task list:
+
+```bash
+./gradlew -q execute -PmainClass=com.uber.cadence.samples.compression.CompressionWorker
+```
+
+## Start a workflow (terminal 2)
+
+```bash
+./gradlew -q execute -PmainClass=com.uber.cadence.samples.compression.CompressionStarter
+```
+
+Or from the Cadence CLI:
+
+```bash
+cadence --domain samples-domain \
+ workflow start \
+ --workflow_type CompressedDataConverterWorkflow \
+ --tl data-compression \
+ --et 60
+```
+
+## How it works
+
+- `toData`: JSON-encode the arguments with the standard `JsonDataConverter`, then write the bytes through `java.util.zip.GZIPOutputStream`.
+- `fromData` / `fromDataArray`: decompress through `GZIPInputStream` with a configurable max output cap, then delegate to the standard `JsonDataConverter`.
+
+## Source layout
+
+| File | Purpose |
+|------|---------|
+| [`CompressedJsonDataConverter.java`](CompressedJsonDataConverter.java) | The custom `DataConverter` |
+| [`CompressedDataConverterWorkflow.java`](CompressedDataConverterWorkflow.java) | Workflow + activity + sample `LargePayload` POJOs and generator |
+| [`CompressionWorker.java`](CompressionWorker.java) | Worker main; wires the converter into `WorkflowClientOptions` and prints the stats banner |
+| [`CompressionStarter.java`](CompressionStarter.java) | Thin async starter |
diff --git a/src/main/java/com/uber/cadence/samples/encryption/EncryptedDataConverterWorkflow.java b/src/main/java/com/uber/cadence/samples/encryption/EncryptedDataConverterWorkflow.java
new file mode 100644
index 00000000..70a3bc4e
--- /dev/null
+++ b/src/main/java/com/uber/cadence/samples/encryption/EncryptedDataConverterWorkflow.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2012-2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Modifications copyright (C) 2017 Uber Technologies, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"). You may not
+ * use this file except in compliance with the License. A copy of the License is
+ * located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package com.uber.cadence.samples.encryption;
+
+import com.uber.cadence.activity.ActivityMethod;
+import com.uber.cadence.activity.ActivityOptions;
+import com.uber.cadence.workflow.Workflow;
+import com.uber.cadence.workflow.WorkflowMethod;
+import java.time.Duration;
+
+/**
+ * Demonstrates AES-256-GCM encryption as a Cadence {@code DataConverter}. Every workflow input,
+ * output, and activity parameter is encrypted before being written to Cadence history. Without the
+ * key, payloads in workflow history are unreadable to anyone browsing history — including Cadence
+ * operators. Application logs, metrics, and search attributes are not encrypted by a DataConverter.
+ *
+ *
+ * cadence --domain samples-domain \
+ * workflow start \
+ * --workflow_type EncryptedDataConverterWorkflow \
+ * --tl data-encryption \
+ * --et 60
+ *
+ */
+public final class EncryptionStarter {
+
+ private EncryptionStarter() {}
+
+ public static void main(String[] args) {
+ try {
+ WorkflowClient client =
+ WorkflowClient.newInstance(
+ new Thrift2ProtoAdapter(IGrpcServiceStubs.newInstance()),
+ WorkflowClientOptions.newBuilder().setDomain(SampleConstants.DOMAIN).build());
+ WorkflowOptions options =
+ new WorkflowOptions.Builder()
+ .setTaskList(EncryptedDataConverterWorkflow.TASK_LIST)
+ .setExecutionStartToCloseTimeout(Duration.ofMinutes(1))
+ .setWorkflowId("encryption-" + UUID.randomUUID())
+ .build();
+
+ EncryptedDataConverterWorkflow.WorkflowIface workflow =
+ client.newWorkflowStub(EncryptedDataConverterWorkflow.WorkflowIface.class, options);
+
+ WorkflowClient.start(workflow::run);
+ System.out.println(
+ "Started "
+ + EncryptedDataConverterWorkflow.WORKFLOW_TYPE
+ + " on task list \""
+ + EncryptedDataConverterWorkflow.TASK_LIST
+ + "\".");
+ System.exit(0);
+ } catch (RuntimeException e) {
+ if (printHintIfDomainMissing(e)) {
+ System.exit(1);
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * Prints a copy-paste hint when the Cadence error indicates the sample domain has not been
+ * registered.
+ *
+ * @return true if {@code t} was a missing-domain error and a hint was printed (caller should
+ * exit).
+ */
+ static boolean printHintIfDomainMissing(Throwable t) {
+ for (Throwable c = t; c != null; c = c.getCause()) {
+ String m = c.getMessage();
+ if (m != null && m.contains("Domain") && m.contains("does not exist")) {
+ System.err.println();
+ System.err.println(
+ "Cadence reported that the domain \"" + SampleConstants.DOMAIN + "\" does not exist.");
+ System.err.println("Register it once against your cluster, then run this again:");
+ System.err.println();
+ System.err.println(
+ " ./gradlew -q execute -PmainClass=com.uber.cadence.samples.common.RegisterDomain");
+ System.err.println();
+ System.err.println("Or with Cadence CLI:");
+ System.err.println(" cadence --domain " + SampleConstants.DOMAIN + " domain register");
+ System.err.println();
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/com/uber/cadence/samples/encryption/EncryptionWorker.java b/src/main/java/com/uber/cadence/samples/encryption/EncryptionWorker.java
new file mode 100644
index 00000000..20086a4f
--- /dev/null
+++ b/src/main/java/com/uber/cadence/samples/encryption/EncryptionWorker.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2012-2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Modifications copyright (C) 2017 Uber Technologies, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"). You may not
+ * use this file except in compliance with the License. A copy of the License is
+ * located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package com.uber.cadence.samples.encryption;
+
+import com.uber.cadence.client.WorkflowClient;
+import com.uber.cadence.client.WorkflowClientOptions;
+import com.uber.cadence.converter.DataConverter;
+import com.uber.cadence.converter.JsonDataConverter;
+import com.uber.cadence.internal.compatibility.Thrift2ProtoAdapter;
+import com.uber.cadence.internal.compatibility.proto.serviceclient.IGrpcServiceStubs;
+import com.uber.cadence.samples.common.SampleConstants;
+import com.uber.cadence.worker.Worker;
+import com.uber.cadence.worker.WorkerFactory;
+
+/**
+ * Hosts the AES-256-GCM encryption sample worker. Constructs a {@link WorkflowClient} configured
+ * with {@link EncryptedJsonDataConverter} so every workflow input, output, and activity parameter
+ * is transparently encrypted before Cadence history sees it. The encryption key comes from {@link
+ * EncryptionKeyLoader} (env var {@code CADENCE_ENCRYPTION_KEY}, or a hardcoded demo key with a
+ * warning).
+ */
+public final class EncryptionWorker {
+
+ private EncryptionWorker() {}
+
+ public static void main(String[] args) {
+ DataConverter converter =
+ new EncryptedJsonDataConverter(EncryptionKeyLoader.loadEncryptionKey());
+ WorkflowClient client =
+ WorkflowClient.newInstance(
+ new Thrift2ProtoAdapter(IGrpcServiceStubs.newInstance()),
+ WorkflowClientOptions.newBuilder()
+ .setDomain(SampleConstants.DOMAIN)
+ .setDataConverter(converter)
+ .build());
+
+ WorkerFactory factory = WorkerFactory.newInstance(client);
+ Worker worker = factory.newWorker(EncryptedDataConverterWorkflow.TASK_LIST);
+ worker.registerWorkflowImplementationTypes(EncryptedDataConverterWorkflow.WorkflowImpl.class);
+ worker.registerActivitiesImplementations(new EncryptedDataConverterWorkflow.ActivitiesImpl());
+ factory.start();
+
+ printEncryptionStats(converter);
+
+ System.out.println(
+ "EncryptionWorker listening on \""
+ + EncryptedDataConverterWorkflow.TASK_LIST
+ + "\" (domain \""
+ + SampleConstants.DOMAIN
+ + "\").");
+
+ Runtime.getRuntime().addShutdownHook(new Thread(factory::shutdown));
+ }
+
+ private static void printEncryptionStats(DataConverter converter) {
+ EncryptedDataConverterWorkflow.SensitiveCustomerRecord record =
+ EncryptedDataConverterWorkflow.createSensitiveCustomerRecord();
+ byte[] plaintext = JsonDataConverter.getInstance().toData(record);
+ byte[] ciphertext = converter.toData(record);
+ int plaintextSize = plaintext == null ? 0 : plaintext.length;
+ int ciphertextSize = ciphertext == null ? 0 : ciphertext.length;
+ String preview = ciphertext == null ? "" : hexPreview(ciphertext, 40);
+
+ System.out.println();
+ System.out.println("=== Encryption Sample Statistics ===");
+ System.out.printf("Plaintext JSON size: %d bytes%n", plaintextSize);
+ System.out.printf(
+ "Encrypted payload: %d bytes (growth: %d bytes vs plaintext JSON)%n",
+ ciphertextSize, ciphertextSize - plaintextSize);
+ System.out.printf("Ciphertext preview: %s%n", preview);
+ System.out.printf(
+ "Start workflow: cadence --domain %s workflow start --tl %s --workflow_type %s --et 60%n",
+ SampleConstants.DOMAIN,
+ EncryptedDataConverterWorkflow.TASK_LIST,
+ EncryptedDataConverterWorkflow.WORKFLOW_TYPE);
+ System.out.println("====================================");
+ System.out.println();
+ }
+
+ private static String hexPreview(byte[] data, int byteLimit) {
+ int len = Math.min(byteLimit, data.length);
+ StringBuilder sb = new StringBuilder(len * 2 + 3);
+ for (int i = 0; i < len; i++) {
+ sb.append(String.format("%02x", data[i] & 0xff));
+ }
+ if (data.length > byteLimit) {
+ sb.append("...");
+ }
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/com/uber/cadence/samples/encryption/README.md b/src/main/java/com/uber/cadence/samples/encryption/README.md
new file mode 100644
index 00000000..a23c8582
--- /dev/null
+++ b/src/main/java/com/uber/cadence/samples/encryption/README.md
@@ -0,0 +1,74 @@
+# Encryption DataConverter Sample
+
+A custom Cadence [`DataConverter`](../../../../../../../../README.md) that JSON-encodes workflow data and then encrypts it with AES-256-GCM. Every workflow input, output, and activity parameter is encrypted before being written to Cadence history. Without the key, payloads stored by the Cadence server are unreadable to operators browsing workflow history.
+
+Note that application logs, metrics, and search attributes are separate disclosure surfaces — a `DataConverter` does not protect them. Treat them accordingly.
+
+- **Task list:** `data-encryption`
+- **Workflow type:** `EncryptedDataConverterWorkflow`
+
+## Prerequisites
+
+1. Cadence server running (e.g. Docker Compose from the [Cadence repo](https://github.com/uber/cadence)).
+2. From the repo root, build: `./gradlew build`.
+
+### Register the domain (required once per cluster)
+
+```bash
+./gradlew -q execute -PmainClass=com.uber.cadence.samples.common.RegisterDomain
+```
+
+Or with the Cadence CLI:
+
+```bash
+cadence --domain samples-domain domain register
+```
+
+### Encryption key
+
+The worker loads its AES-256 key from the `CADENCE_ENCRYPTION_KEY` environment variable (64 hex characters = 32 bytes). If the env var is unset, the worker falls back to a hardcoded demo key and prints a warning — **never use the demo key in production**. If the env var is set but invalid, the worker fails fast instead of silently using the demo key.
+
+Generate a key:
+
+```bash
+export CADENCE_ENCRYPTION_KEY=$(openssl rand -hex 32)
+```
+
+## Run the worker (terminal 1)
+
+The worker prints an encryption statistics banner showing plaintext vs ciphertext size and a hex preview, then begins polling the `data-encryption` task list:
+
+```bash
+./gradlew -q execute -PmainClass=com.uber.cadence.samples.encryption.EncryptionWorker
+```
+
+## Start a workflow (terminal 2)
+
+```bash
+./gradlew -q execute -PmainClass=com.uber.cadence.samples.encryption.EncryptionStarter
+```
+
+Or from the Cadence CLI:
+
+```bash
+cadence --domain samples-domain \
+ workflow start \
+ --workflow_type EncryptedDataConverterWorkflow \
+ --tl data-encryption \
+ --et 60
+```
+
+## How it works
+
+- `toData`: JSON-encode the arguments with the standard `JsonDataConverter`, then encrypt with `AES/GCM/NoPadding` using a fresh 12-byte random nonce. The output layout is `nonce(12 bytes) || ciphertext || tag(16 bytes)`. A new nonce per call preserves semantic security for repeated payloads.
+- `fromData` / `fromDataArray`: split nonce + ciphertext, run AES-GCM decrypt (which authenticates the tag and fails on any tampering), then delegate to `JsonDataConverter`.
+
+## Source layout
+
+| File | Purpose |
+|------|---------|
+| [`EncryptedJsonDataConverter.java`](EncryptedJsonDataConverter.java) | The custom `DataConverter` |
+| [`EncryptionKeyLoader.java`](EncryptionKeyLoader.java) | Reads the 32-byte key from `CADENCE_ENCRYPTION_KEY` or the demo fallback |
+| [`EncryptedDataConverterWorkflow.java`](EncryptedDataConverterWorkflow.java) | Workflow + activity + sample `SensitiveCustomerRecord` POJO and generator |
+| [`EncryptionWorker.java`](EncryptionWorker.java) | Worker main; wires the converter into `WorkflowClientOptions` and prints the stats banner |
+| [`EncryptionStarter.java`](EncryptionStarter.java) | Thin async starter |
diff --git a/src/main/java/com/uber/cadence/samples/query/LunchVoteWorkflow.java b/src/main/java/com/uber/cadence/samples/query/LunchVoteWorkflow.java
index 24f3f124..80baa54a 100644
--- a/src/main/java/com/uber/cadence/samples/query/LunchVoteWorkflow.java
+++ b/src/main/java/com/uber/cadence/samples/query/LunchVoteWorkflow.java
@@ -39,8 +39,8 @@ private LunchVoteWorkflow() {}
/**
* Signal payload for a lunch vote. Public fields are required so Cadence's JSON data converter
- * can deserialize the signal input. Field names must match the JSON keys in the Markdoc
- * {@code input=} attribute (e.g. {@code input={"location":"Farmhouse","meal":"Red Thai Curry"}}).
+ * can deserialize the signal input. Field names must match the JSON keys in the Markdoc {@code
+ * input=} attribute (e.g. {@code input={"location":"Farmhouse","meal":"Red Thai Curry"}}).
*/
public static class LunchOrder {
public String location;
@@ -64,9 +64,10 @@ public LunchOrder(String location, String meal, String requests) {
public interface WorkflowIface {
@WorkflowMethod(
- name = QueryConstants.LUNCH_VOTE_WORKFLOW_TYPE,
- executionStartToCloseTimeoutSeconds = 700,
- taskList = TASK_LIST)
+ name = QueryConstants.LUNCH_VOTE_WORKFLOW_TYPE,
+ executionStartToCloseTimeoutSeconds = 700,
+ taskList = TASK_LIST
+ )
void run();
/** Visible as "options" in the Cadence Web Query dropdown. */
@@ -74,8 +75,8 @@ public interface WorkflowIface {
MarkdownFormattedResponse optionsQuery();
/**
- * {@code name} sets the signal type string the worker listens for. It must match the
- * {@code signalName} attribute in the Markdoc template so Cadence Web sends the right signal.
+ * {@code name} sets the signal type string the worker listens for. It must match the {@code
+ * signalName} attribute in the Markdoc template so Cadence Web sends the right signal.
*/
@SignalMethod(name = "lunch_order")
void lunchOrder(LunchOrder vote);
@@ -136,8 +137,10 @@ public MarkdownFormattedResponse optionsQuery() {
return new MarkdownFormattedResponse(data);
}
- /** Builds a Markdoc {@code {%- signal -%}} tag. Every attribute is required for Cadence Web
- * to route the signal to the correct workflow execution. */
+ /**
+ * Builds a Markdoc {@code {%- signal -%}} tag. Every attribute is required for Cadence Web to
+ * route the signal to the correct workflow execution.
+ */
private static String signalBlock(
String workflowId, String runId, String label, String jsonInput) {
return "{% signal \n"
diff --git a/src/main/java/com/uber/cadence/samples/query/MarkdownQueryWorkflow.java b/src/main/java/com/uber/cadence/samples/query/MarkdownQueryWorkflow.java
index 31268e12..439c2e4e 100644
--- a/src/main/java/com/uber/cadence/samples/query/MarkdownQueryWorkflow.java
+++ b/src/main/java/com/uber/cadence/samples/query/MarkdownQueryWorkflow.java
@@ -43,17 +43,18 @@ private MarkdownQueryWorkflow() {}
*
*
*
*/
public interface WorkflowIface {
@WorkflowMethod(
- name = QueryConstants.MARKDOWN_QUERY_WORKFLOW_TYPE,
- executionStartToCloseTimeoutSeconds = 3600,
- taskList = TASK_LIST)
+ name = QueryConstants.MARKDOWN_QUERY_WORKFLOW_TYPE,
+ executionStartToCloseTimeoutSeconds = 3600,
+ taskList = TASK_LIST
+ )
void run();
/**
@@ -96,7 +97,9 @@ public static final class WorkflowImpl implements WorkflowIface {
private String cachedWorkflowId = "";
private String cachedRunId = "";
- /** Set by {@link #refreshSuggestedStartWorkflowId()} in {@code run()} before any query executes. */
+ /**
+ * Set by {@link #refreshSuggestedStartWorkflowId()} in {@code run()} before any query executes.
+ */
private String suggestedNewWorkflowId = "";
@Override
diff --git a/src/main/java/com/uber/cadence/samples/query/OrderFulfillmentModels.java b/src/main/java/com/uber/cadence/samples/query/OrderFulfillmentModels.java
index b0a6f1f0..3dbc4cfb 100644
--- a/src/main/java/com/uber/cadence/samples/query/OrderFulfillmentModels.java
+++ b/src/main/java/com/uber/cadence/samples/query/OrderFulfillmentModels.java
@@ -50,8 +50,7 @@ public static class Order {
public String customerEmail = "alice.johnson@example.com";
public OrderItem[] items =
new OrderItem[] {
- new OrderItem("Wireless Headphones", 2, 79.99),
- new OrderItem("Phone Case", 1, 19.99),
+ new OrderItem("Wireless Headphones", 2, 79.99), new OrderItem("Phone Case", 1, 19.99),
};
public double totalAmount = 179.97;
public String status = STATUS_PENDING_PAYMENT;
@@ -86,8 +85,8 @@ public static class ActionLogEntry {
/**
* Signal POJOs below use public fields so the Cadence JSON data converter can deserialize them.
* Field names must match the JSON keys in each Markdoc {@code input=} attribute; for example
- * {@code input={"operator":"admin","reason":"Fraud"}} maps to {@link #operator} and
- * {@link #reason}.
+ * {@code input={"operator":"admin","reason":"Fraud"}} maps to {@link #operator} and {@link
+ * #reason}.
*/
public static class RejectPaymentSignal {
public String reason;
diff --git a/src/main/java/com/uber/cadence/samples/query/OrderFulfillmentWorkflow.java b/src/main/java/com/uber/cadence/samples/query/OrderFulfillmentWorkflow.java
index 9620aca1..18d95ce8 100644
--- a/src/main/java/com/uber/cadence/samples/query/OrderFulfillmentWorkflow.java
+++ b/src/main/java/com/uber/cadence/samples/query/OrderFulfillmentWorkflow.java
@@ -51,16 +51,17 @@ private OrderFulfillmentWorkflow() {}
/**
* Dashboard pattern: one query method renders the full markdown UI (tables, status, action
- * buttons), and multiple signal methods drive state transitions on the order. The {@code name}
- * on each {@code @SignalMethod} must match the {@code signalName} in the Markdoc template;
- * without {@code name}, the Java SDK would default to {@code WorkflowIface::methodName}.
+ * buttons), and multiple signal methods drive state transitions on the order. The {@code name} on
+ * each {@code @SignalMethod} must match the {@code signalName} in the Markdoc template; without
+ * {@code name}, the Java SDK would default to {@code WorkflowIface::methodName}.
*/
public interface WorkflowIface {
@WorkflowMethod(
- name = QueryConstants.ORDER_FULFILLMENT_WORKFLOW_TYPE,
- executionStartToCloseTimeoutSeconds = 3600,
- taskList = TASK_LIST)
+ name = QueryConstants.ORDER_FULFILLMENT_WORKFLOW_TYPE,
+ executionStartToCloseTimeoutSeconds = 3600,
+ taskList = TASK_LIST
+ )
void run();
/** Visible as "dashboard" in the Cadence Web Query dropdown. */
@@ -101,9 +102,8 @@ public static final class WorkflowImpl implements WorkflowIface {
/**
* Inbox for signal-to-main-loop communication. Signal handlers (which execute on the workflow
- * thread but outside the main loop) enqueue messages here. The main {@link #run()} loop
- * drains the inbox one message at a time, keeping state transitions sequential and
- * deterministic.
+ * thread but outside the main loop) enqueue messages here. The main {@link #run()} loop drains
+ * the inbox one message at a time, keeping state transitions sequential and deterministic.
*/
private final ArrayDeque