Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .scripts/copy-shared-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const TSCONFIG_EXCLUDE = [
'scratchpad',
];
const GITIGNORE_EXCLUDE = [
'external-storage',
'nextjs-ecommerce-oneclick',
'monorepo-folders',
'production',
Expand Down
1 change: 1 addition & 0 deletions .scripts/list-of-samples.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"encryption",
"env-config",
"expense",
"external-storage",
"fetch-esm",
"food-delivery",
"grpc-calls",
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ and you'll be given the list of sample options.
- [**Worker Versioning**](./worker-versioning): Version Workers with Build IDs in order to deploy incompatible changes to Workflow code.
- [**Protobufs**](./protobufs): Use [Protobufs](https://docs.temporal.io/security/#default-data-converter).
- [**Custom Payload Converter**](./ejson): Customize data serialization by creating a `PayloadConverter` that uses EJSON to convert Dates, binary, and regexes.
- [**External Storage**](./external-storage): Keep large payloads out of Workflow History by writing a custom `StorageDriver` that offloads them to disk, where other Workers can read them back.
- **Monorepos**:
- [`/monorepo-folders`](./monorepo-folders): yarn workspace with packages for a web frontend, API server, Worker, and Workflows/Activities.
- [`psigen/temporal-ts-example`](https://github.com/psigen/temporal-ts-example): yarn workspace containerized with [tilt](https://tilt.dev/). Includes `temporalite`, `parcel`, and different packages for Workflows and Activities.
Expand Down
3 changes: 3 additions & 0 deletions external-storage/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
lib
.eslintrc.js
48 changes: 48 additions & 0 deletions external-storage/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const { builtinModules } = require('module');

const ALLOWED_NODE_BUILTINS = new Set(['assert']);

module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
},
plugins: ['@typescript-eslint', 'deprecation'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
rules: {
// recommended for safety
'@typescript-eslint/no-floating-promises': 'error', // forgetting to await Activities and Workflow APIs is bad
'deprecation/deprecation': 'warn',

// code style preference
'object-shorthand': ['error', 'always'],

// relaxed rules, for convenience
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'off',
},
overrides: [
{
files: ['src/**/workflows.ts', 'src/**/workflows-*.ts', 'src/**/workflows/*.ts'],
rules: {
'no-restricted-imports': [
'error',
...builtinModules.filter((m) => !ALLOWED_NODE_BUILTINS.has(m)).flatMap((m) => [m, `node:${m}`]),
],
},
},
],
};
3 changes: 3 additions & 0 deletions external-storage/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
lib
node_modules
storage
1 change: 1 addition & 0 deletions external-storage/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package-lock=false
1 change: 1 addition & 0 deletions external-storage/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
18 changes: 18 additions & 0 deletions external-storage/.post-create
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
To begin development, install the Temporal CLI:

Mac: {cyan brew install temporal}
Other: Download and extract the latest release from https://github.com/temporalio/cli/releases/latest

Start Temporal Server:

{cyan temporal server start-dev}

Use Node version 18+ (v22.x is recommended):

Mac: {cyan brew install node@22}
Other: https://nodejs.org/en/download/

Then, in the project directory, using two other shells, run these commands:

{cyan npm run start.watch}
{cyan npm run workflow}
1 change: 1 addition & 0 deletions external-storage/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
lib
2 changes: 2 additions & 0 deletions external-storage/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
printWidth: 120
singleQuote: true
156 changes: 156 additions & 0 deletions external-storage/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# External Storage

> **Experimental.** External storage shipped in SDK 1.21.0 and is marked experimental;
> its API may change.

Temporal stores every Workflow argument, Activity result, Signal, and heartbeat detail in
Workflow History, and enforces a size limit on each one. External storage moves the large
ones out: payloads over a size threshold are written to storage you control and replaced
on the wire by a small reference. The retrieving side resolves the reference before your
code ever sees it, so Workflow and Activity code stays unchanged.

This sample writes a **custom driver** end to end. It keeps payloads on the filesystem, so
a Client in one process can hand a 1 MiB argument to a Worker in another process without
either of them putting it in the Temporal database.

## How it works

`ExternalStorage` is configured on the `DataConverter`, on both the Client and the Worker:

```ts
new Client({
connection,
dataConverter: {
externalStorage: new ExternalStorage({
drivers: [new FileSystemStorageDriver({ rootDir })],
payloadSizeThreshold: 32 * 1024,
}),
},
});
```

A driver is four members:

```ts
interface StorageDriver {
readonly name: string; // routing key written into the reference; must match across processes
readonly type: string; // stable implementation ID, reported via Worker heartbeat
store(context, payloads): Promise<StorageDriverClaim[]>;
retrieve(context, claims): Promise<Payload[]>;
}
```

The SDK handles the rest: it measures each payload, batches the over-threshold ones per
driver, calls `store`, and swaps in a reference carrying the driver name and the claim you
returned. Ordering is your contract to keep, one claim per payload. A claim is an opaque
`Record<string, string>`, and it lands in Workflow History, so keep it small and free of
secrets.

`store` receives a `target` describing the Workflow or Activity that produced the payloads
(namespace, ID, run ID, type), which this driver uses to lay out keys. If a driver throws,
the enclosing Workflow or Activity Task fails **retryably**, so transient I/O errors
recover on their own.

## Code

- [`filesystem-storage-driver.ts`](./src/filesystem-storage-driver.ts) — the custom driver.
Content-addresses each payload by SHA-256, writes it atomically, verifies the hash on
read, and refuses keys that escape the storage root. The comments cover the decisions
and the alternatives at each one.
- [`data-converter.ts`](./src/data-converter.ts) — wires the driver into an
`ExternalStorage`, with the size threshold and the multi-driver `driverSelector` option.
- [`workflows.ts`](./src/workflows.ts) — an ordinary Workflow. Documents which of its four
payloads get offloaded, by whom, and when.
- [`activities.ts`](./src/activities.ts) — one Activity that takes and returns a large
payload, one that takes a large payload and returns a small one.
- [`worker.ts`](./src/worker.ts) / [`client.ts`](./src/client.ts) — both sides configured.
- [`inspect.ts`](./src/inspect.ts) — prints the references Temporal Server actually holds
alongside the blobs on disk they point at.

## Running this sample

1. `temporal server start-dev` to start [Temporal Server](https://github.com/temporalio/cli/#installation).
1. `npm install` to install dependencies.
1. `npm run start.watch` to start the Worker.
1. In another shell, `npm run workflow` to run the Workflow Client.
1. `npm run inspect <workflowId>` to see what was stored where.

The Client passes a 1 MiB document and gets ~1 MiB of extracted text back. Inline, that
same payload would cross the wire five times, each crossing over the SDK's default 512 KiB
outbound size warning and pushing toward the server's 2 MiB per-payload limit:

```
Starting workflow with a 1048590 byte document
Payloads of 32768 bytes or more are offloaded to .../external-storage/storage
Started workflow document-V1StGXR8_Z5jdHi6B
Summary: 17404 lines, 165338 words, 1048590 characters
Received 1048590 bytes of extracted text

To see what the server actually stored, run:
npm run inspect document-V1StGXR8_Z5jdHi6B
```

`npm run inspect` then shows the same execution from the server's side. Every large
payload is a reference of a few hundred bytes; the small one was left inline (keys
abbreviated here):

```
History payloads for document-V1StGXR8_Z5jdHi6B:

#1 WORKFLOW_EXECUTION_STARTED: 300 bytes on the wire (reference to 1066065 bytes in 'sample.filesystemdriver', key=v1/wf/default/processDocument/document-.../null/sha256/1558f39e...)
#5 ACTIVITY_TASK_SCHEDULED: 332 bytes on the wire (reference to 1066065 bytes in 'sample.filesystemdriver', key=v1/wf/.../d0dd96bc-.../sha256/1558f39e...)
#7 ACTIVITY_TASK_COMPLETED: 332 bytes on the wire (reference to 1066023 bytes in 'sample.filesystemdriver', key=v1/wf/.../d0dd96bc-.../sha256/dfb0fab1...)
#11 ACTIVITY_TASK_SCHEDULED: 332 bytes on the wire (reference to 1066023 bytes in 'sample.filesystemdriver', key=v1/wf/.../d0dd96bc-.../sha256/dfb0fab1...)
#13 ACTIVITY_TASK_COMPLETED: 47 bytes on the wire (inline)
#17 WORKFLOW_EXECUTION_COMPLETED: 332 bytes on the wire (reference to 1066137 bytes in 'sample.filesystemdriver', key=v1/wf/.../d0dd96bc-.../sha256/0a4efcbf...)
```

Four things this output shows:

- **The Client and the Worker are separate processes.** The blob behind event #1 was
written by the Client and read by the Worker. Nothing coordinates them beyond both
drivers resolving the same directory.
- **The threshold is per payload, not per Activity.** `summarize` returned 47 bytes at
#13, under the 32 KiB threshold, so it stayed inline. Its 1 MiB _argument_ at #11 did
not.
- **Identical content deduplicates.** #7 and #11 are the same key: the extracted text was
stored once when the Activity completed, and the reference was reused when it became the
next Activity's argument.
- **Deduplication stops at the key prefix.** #1 and #5 have the same hash under different
prefixes, so the document is on disk twice. The Client stored it before a run ID
existed, hence the `null` segment; the Worker stored it again under the real run ID.
Four blobs, ~4 MiB, for one 1 MiB document. `buildKeyPrefix` in the driver explains the
tradeoff and how to trade it the other way.

## Testing

`npm test` runs both suites:

- [`filesystem-storage-driver.test.ts`](./src/test/filesystem-storage-driver.test.ts) —
the driver on its own: byte-for-byte round trips, deduplication, a second driver
instance reading what the first wrote, and the failure paths (corrupted blob, hostile
Workflow ID, claim pointing outside the storage root, oversized payload).
- [`workflows.test.ts`](./src/test/workflows.test.ts) — the Workflow against a test
server, asserting the large payloads are references in History and that below-threshold
payloads are not offloaded at all.

## Before using this in production

- **Prefer a vended driver.** The SDK ships
[`@temporalio/external-storage-s3`](https://github.com/temporalio/sdk-typescript/tree/main/contrib/external-storage-s3)
and
[`@temporalio/external-storage-gcs`](https://github.com/temporalio/sdk-typescript/tree/main/contrib/external-storage-gcs).
Write your own only if neither backend fits. This driver exists to show what the
interface asks of you.
- **Shared storage is required.** A local directory only works here because everything
runs on one machine. Real Workers need storage all of them can reach.
- **Nothing deletes blobs.** Retention is on you: an object-store lifecycle policy, or a
reaper keyed on Workflow completion. Blobs must outlive every History that references
them, including retention on completed Executions, replay, and Workflow Reset.
- **Payloads leave Temporal's trust boundary.** Whatever you offload is now protected by
your storage's access control and encryption at rest, not Temporal's. Combine with a
`PayloadCodec` if you need the bytes encrypted before they land there (see the
[encryption](../encryption) sample).
- **The Web UI shows references, not values.** Offloaded payloads render as
`ExternalStorageReference` in History. A [Codec Server](https://docs.temporal.io/production-deployment/data-encryption)
can resolve them for viewing.
54 changes: 54 additions & 0 deletions external-storage/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"name": "temporal-external-storage",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc --build",
"build.watch": "tsc --build --watch",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint .",
"test": "mocha --require ts-node/register --require source-map-support/register src/test/*.test.ts",
"test.watch": "mocha --require ts-node/register --require source-map-support/register src/test/*.test.ts -w --watch-files src",
"start": "ts-node src/worker.ts",
"start.watch": "nodemon src/worker.ts",
"workflow": "ts-node src/client.ts",
"inspect": "ts-node src/inspect.ts"
},
"nodemonConfig": {
"execMap": {
"ts": "ts-node"
},
"ext": "ts",
"watch": [
"src"
]
},
"dependencies": {
"@temporalio/activity": "^1.21.0",
"@temporalio/client": "^1.21.0",
"@temporalio/common": "^1.21.0",
"@temporalio/envconfig": "^1.21.0",
"@temporalio/proto": "^1.21.0",
"@temporalio/worker": "^1.21.0",
"@temporalio/workflow": "^1.21.0",
"nanoid": "^3.3.8"
},
"devDependencies": {
"@temporalio/testing": "^1.21.0",
"@tsconfig/node22": "^22.0.0",
"@types/mocha": "^9.1.1",
"@types/node": "^22.9.1",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-deprecation": "^3.0.0",
"mocha": "^10.0.0",
"nodemon": "^3.1.7",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.6.3"
}
}
35 changes: 35 additions & 0 deletions external-storage/src/activities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { log } from '@temporalio/activity';
import type { Document } from './shared';

/**
* Stands in for OCR or text extraction: takes a large document in and returns a large
* string out. Both the argument and the return value are offloaded, so neither ever
* reaches Temporal Server.
*
* Nothing here is aware of external storage. By the time the Activity runs, the Worker
* has already retrieved the argument through the driver; the returned string is stored
* on the way back out.
*/
export async function extractText(document: Document): Promise<string> {
log.info('extracting text', { document: document.name, contentLength: document.content.length });

const extractedText = document.content
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('\n');

log.info('extracted text', { extractedLength: extractedText.length });
return extractedText;
}

/**
* Takes a large argument and returns a small result, so its argument is offloaded and
* its return value stays inline. Mixing both in one Workflow shows the threshold at
* work: offloading is decided per payload, by size, not per Activity.
*/
export async function summarize(text: string): Promise<string> {
const lines = text.split('\n');
const words = text.split(/\s+/).filter((word) => word.length > 0);
return `${lines.length} lines, ${words.length} words, ${text.length} characters`;
}
Loading