Skip to content

Commit cfc8878

Browse files
authored
feat(extstore): add extstore s3 driver (#2129)
1 parent a5aa967 commit cfc8878

16 files changed

Lines changed: 1479 additions & 20 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# AWS SDK Client for the Temporal S3 External Storage Driver
2+
3+
> ⚠️ **This package is experimental and may be subject to change.** ⚠️
4+
5+
`@temporalio/external-storage-s3-aws-sdk` provides an [`@aws-sdk/client-s3`](https://www.npmjs.com/package/@aws-sdk/client-s3)-backed `S3StorageDriverClient` for [`@temporalio/external-storage-s3`](../external-storage-s3).
6+
7+
`@aws-sdk/client-s3` is a peer dependency, so the driver uses the same `S3Client` (and version) your application already configures.
8+
9+
## Usage
10+
11+
npm install @temporalio/external-storage-s3 @temporalio/external-storage-s3-aws-sdk @aws-sdk/client-s3
12+
13+
```ts
14+
import { S3Client } from '@aws-sdk/client-s3';
15+
import { S3StorageDriver } from '@temporalio/external-storage-s3';
16+
import { AwsSdkS3StorageDriverClient } from '@temporalio/external-storage-s3-aws-sdk';
17+
18+
const s3Client = new S3Client({ region: 'us-east-1' });
19+
const driver = new S3StorageDriver({
20+
client: new AwsSdkS3StorageDriverClient(s3Client),
21+
bucket: 'my-temporal-payloads',
22+
});
23+
```
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"name": "@temporalio/external-storage-s3-aws-sdk",
3+
"version": "1.18.1",
4+
"private": true,
5+
"description": "AWS SDK (@aws-sdk/client-s3) client for the Temporal S3 external storage driver",
6+
"main": "lib/index.js",
7+
"types": "./lib/index.d.ts",
8+
"keywords": [
9+
"temporal",
10+
"workflow",
11+
"external storage",
12+
"payload",
13+
"s3",
14+
"aws"
15+
],
16+
"author": "Temporal Technologies Inc. <sdk@temporal.io>",
17+
"license": "MIT",
18+
"scripts": {
19+
"build": "tsc --build",
20+
"test": "ava ./lib/__tests__/test-*.js"
21+
},
22+
"ava": {
23+
"timeout": "60s"
24+
},
25+
"dependencies": {
26+
"@temporalio/external-storage-s3": "workspace:*"
27+
},
28+
"peerDependencies": {
29+
"@aws-sdk/client-s3": "^3.0.0"
30+
},
31+
"devDependencies": {
32+
"@aws-sdk/client-s3": "^3.0.0",
33+
"ava": "^5.3.1"
34+
},
35+
"engines": {
36+
"node": ">= 20.3.0"
37+
},
38+
"bugs": {
39+
"url": "https://github.com/temporalio/sdk-typescript/issues"
40+
},
41+
"repository": {
42+
"type": "git",
43+
"url": "git+https://github.com/temporalio/sdk-typescript.git",
44+
"directory": "contrib/external-storage-s3-aws-sdk"
45+
},
46+
"homepage": "https://github.com/temporalio/sdk-typescript/tree/main/contrib/external-storage-s3-aws-sdk",
47+
"publishConfig": {
48+
"access": "public"
49+
},
50+
"files": [
51+
"src",
52+
"lib",
53+
"!src/__tests__",
54+
"!lib/__tests__"
55+
]
56+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import test from 'ava';
2+
import type { S3Client } from '@aws-sdk/client-s3';
3+
import { AwsSdkS3StorageDriverClient } from '../aws-sdk-client';
4+
5+
function fakeS3Client(send: (command: unknown) => Promise<unknown>, region?: string): S3Client {
6+
return { send, config: { region } } as unknown as S3Client;
7+
}
8+
9+
test('objectExists maps a NotFound error to false', async (t) => {
10+
const client = new AwsSdkS3StorageDriverClient(
11+
fakeS3Client(() => Promise.reject(Object.assign(new Error('not found'), { name: 'NotFound' })))
12+
);
13+
t.false(await client.objectExists('b', 'k'));
14+
});
15+
16+
test('objectExists maps a 404 status to false', async (t) => {
17+
const client = new AwsSdkS3StorageDriverClient(
18+
fakeS3Client(() => Promise.reject(Object.assign(new Error('nope'), { $metadata: { httpStatusCode: 404 } })))
19+
);
20+
t.false(await client.objectExists('b', 'k'));
21+
});
22+
23+
test('objectExists rethrows non-404 errors', async (t) => {
24+
const client = new AwsSdkS3StorageDriverClient(
25+
fakeS3Client(() => Promise.reject(Object.assign(new Error('denied'), { $metadata: { httpStatusCode: 403 } })))
26+
);
27+
await t.throwsAsync(() => client.objectExists('b', 'k'), { message: 'denied' });
28+
});
29+
30+
test('objectExists returns true when the head succeeds', async (t) => {
31+
const client = new AwsSdkS3StorageDriverClient(fakeS3Client(() => Promise.resolve({})));
32+
t.true(await client.objectExists('b', 'k'));
33+
});
34+
35+
test('getObject reads the response body as bytes', async (t) => {
36+
const bytes = new Uint8Array([1, 2, 3]);
37+
const client = new AwsSdkS3StorageDriverClient(
38+
fakeS3Client(() => Promise.resolve({ Body: { transformToByteArray: async () => bytes } }))
39+
);
40+
t.deepEqual(await client.getObject('b', 'k'), bytes);
41+
});
42+
43+
test('getObject throws when the response has no body', async (t) => {
44+
const client = new AwsSdkS3StorageDriverClient(fakeS3Client(() => Promise.resolve({})));
45+
await t.throwsAsync(() => client.getObject('b', 'k'), { message: /empty body/ });
46+
});
47+
48+
test('describe surfaces a plain-string region', (t) => {
49+
const client = new AwsSdkS3StorageDriverClient(fakeS3Client(() => Promise.resolve({}), 'us-west-2'));
50+
t.deepEqual(client.describe?.(), { clientRegion: 'us-west-2' });
51+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { PutObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
2+
import type { S3Client } from '@aws-sdk/client-s3';
3+
import type { S3StorageDriverClient, S3RequestOptions } from '@temporalio/external-storage-s3';
4+
5+
/**
6+
* An {@link S3StorageDriverClient} backed by an `@aws-sdk/client-s3` `S3Client`,
7+
* for use with `S3StorageDriver`.
8+
*
9+
* @experimental
10+
*/
11+
export class AwsSdkS3StorageDriverClient implements S3StorageDriverClient {
12+
constructor(private readonly client: S3Client) {}
13+
14+
describe(): Record<string, string> {
15+
const region = this.client.config?.region;
16+
return typeof region === 'string' && region ? { clientRegion: region } : {};
17+
}
18+
19+
async objectExists(bucket: string, key: string, options?: S3RequestOptions): Promise<boolean> {
20+
try {
21+
await this.client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }), {
22+
abortSignal: options?.abortSignal,
23+
});
24+
return true;
25+
} catch (err) {
26+
if (isNotFound(err)) {
27+
return false;
28+
}
29+
throw err;
30+
}
31+
}
32+
33+
async putObject(bucket: string, key: string, data: Uint8Array, options?: S3RequestOptions): Promise<void> {
34+
await this.client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: data }), {
35+
abortSignal: options?.abortSignal,
36+
});
37+
}
38+
39+
async getObject(bucket: string, key: string, options?: S3RequestOptions): Promise<Uint8Array> {
40+
const response = await this.client.send(new GetObjectCommand({ Bucket: bucket, Key: key }), {
41+
abortSignal: options?.abortSignal,
42+
});
43+
if (!response.Body) {
44+
throw new Error(`S3 GetObject returned an empty body [bucket=${bucket}, key=${key}]`);
45+
}
46+
return response.Body.transformToByteArray();
47+
}
48+
}
49+
50+
function isNotFound(err: unknown): boolean {
51+
const e = err as { name?: string; $metadata?: { httpStatusCode?: number } };
52+
return e?.name === 'NotFound' || e?.$metadata?.httpStatusCode === 404;
53+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/**
2+
* @experimental The External Storage S3 driver is an experimental feature and may be subject to change.
3+
*/
4+
export { AwsSdkS3StorageDriverClient } from './aws-sdk-client';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"outDir": "./lib",
5+
"rootDir": "./src"
6+
},
7+
"references": [{ "path": "../external-storage-s3" }],
8+
"include": ["./src/**/*.ts"]
9+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# Amazon S3 External Storage Driver for the Temporal TypeScript SDK
2+
3+
> ⚠️ **This package is experimental and may be subject to change.** ⚠️
4+
5+
`@temporalio/external-storage-s3` stores and retrieves Temporal payloads in Amazon S3 via the [External Storage](https://docs.temporal.io/external-storage) feature.
6+
7+
This package has no AWS dependency: it defines the driver and the `S3StorageDriverClient` interface, and you supply the S3 client. Use the companion [`@temporalio/external-storage-s3-aws-sdk`](../external-storage-s3-aws-sdk) package for an [`@aws-sdk/client-s3`](https://www.npmjs.com/package/@aws-sdk/client-s3)-backed client, or implement the interface yourself.
8+
9+
## Using the AWS SDK client
10+
11+
Install the adapter package alongside this one:
12+
13+
npm install @temporalio/external-storage-s3 @temporalio/external-storage-s3-aws-sdk @aws-sdk/client-s3
14+
15+
```ts
16+
import { S3Client } from '@aws-sdk/client-s3';
17+
import { S3StorageDriver } from '@temporalio/external-storage-s3';
18+
import { AwsSdkS3StorageDriverClient } from '@temporalio/external-storage-s3-aws-sdk';
19+
20+
const s3Client = new S3Client({ region: 'us-east-1' });
21+
const driver = new S3StorageDriver({
22+
client: new AwsSdkS3StorageDriverClient(s3Client),
23+
bucket: 'my-temporal-payloads',
24+
});
25+
```
26+
27+
Register the resulting driver with the SDK's External Storage configuration so the
28+
client and worker offload eligible payloads to it.
29+
30+
## Custom S3 client implementations
31+
32+
To use a different S3 library, implement `S3StorageDriverClient`.
33+
34+
```ts
35+
import type { S3StorageDriverClient } from '@temporalio/external-storage-s3';
36+
37+
const myClient: S3StorageDriverClient = {
38+
async putObject(bucket, key, data, options) {
39+
/* ... */
40+
},
41+
async objectExists(bucket, key, options) {
42+
/* ... */
43+
return false;
44+
},
45+
async getObject(bucket, key, options) {
46+
/* ... */
47+
return new Uint8Array();
48+
},
49+
};
50+
```
51+
52+
## Dynamic bucket selection
53+
54+
Pass a callable as `bucket` to choose the destination per payload:
55+
56+
```ts
57+
const driver = new S3StorageDriver({
58+
client: new AwsSdkS3StorageDriverClient(s3Client),
59+
bucket: (_context, payload) => ((payload.data?.length ?? 0) > 10 * 1024 * 1024 ? 'large-payloads' : 'small-payloads'),
60+
});
61+
```
62+
63+
## Required IAM permissions
64+
65+
The credentials used by your S3 client must have these S3 permissions on the target bucket and its objects:
66+
67+
```json
68+
{
69+
"Effect": "Allow",
70+
"Action": ["s3:PutObject", "s3:GetObject"],
71+
"Resource": "arn:aws:s3:::my-temporal-payloads/*"
72+
}
73+
```
74+
75+
`s3:PutObject` is required by components that store payloads (typically the client and worker sending workflow/activity inputs); `s3:GetObject` is required by components that retrieve them. Components that only retrieve do not need `s3:PutObject`, and vice versa.
76+
77+
## S3 Storage Key Specification
78+
79+
All Temporal S3 drivers generate S3 keys in a consistent manner.
80+
81+
### Key format
82+
83+
Workflow key:
84+
85+
```text
86+
v0/ns/{namespace}/wt/{workflow-type}/wi/{workflow-id}/ri/{run-id}/d/{hash-algorithm}/{hex-digest}
87+
```
88+
89+
Activity key:
90+
91+
```text
92+
v0/ns/{namespace}/at/{activity-type}/ai/{activity-id}/ri/{run-id}/d/{hash-algorithm}/{hex-digest}
93+
```
94+
95+
Fallback key (unknown target):
96+
97+
```text
98+
v0/d/{hash-algorithm}/{hex-digest}
99+
```
100+
101+
- If no namespace, workflow, or activity information is available, the fallback is used.
102+
- Dynamic path segments are percent-encoded (rules below).
103+
- Missing values (including a missing `run-id`) are encoded as `null`.
104+
- `hex-digest` is lower-case SHA-256 hex (64 characters).
105+
106+
### Percent-encoding rules
107+
108+
The Temporal SDKs escape anything that isn't listed in S3's safe character set: https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html
109+
110+
Safe Characters:
111+
112+
```text
113+
Alphanumeric characters
114+
0-9
115+
a-z
116+
A-Z
117+
118+
Special characters
119+
Exclamation point (!)
120+
Hyphen (-)
121+
Underscore (_)
122+
Period (.)
123+
Asterisk (*)
124+
Single quotation mark (')
125+
Opening parenthesis (()
126+
Closing parenthesis ())
127+
```
128+
129+
### Examples
130+
131+
Workflow key example:
132+
133+
```text
134+
input:
135+
namespace=payments prod
136+
workflow-type=ChargeWorkflow
137+
workflow-id=order+123=abc
138+
run-id=3f1d6c7a-8b2e-4f7a-9d0a-87a6f95e4d31
139+
hash-algorithm=sha256
140+
hex-digest=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
141+
142+
output:
143+
v0/ns/payments%20prod/wt/ChargeWorkflow/wi/order%2B123%3Dabc/ri/3f1d6c7a-8b2e-4f7a-9d0a-87a6f95e4d31/d/sha256/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
144+
```
145+
146+
Activity key example:
147+
148+
```text
149+
input:
150+
namespace=payments prod
151+
activity-type=Capture/Charge
152+
activity-id=activity id+42
153+
run-id=9e1d1fd9-2f8a-4c40-93e2-731f31b9268b
154+
hash-algorithm=sha256
155+
hex-digest=2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
156+
157+
output:
158+
v0/ns/payments%20prod/at/Capture%2FCharge/ai/activity%20id%2B42/ri/9e1d1fd9-2f8a-4c40-93e2-731f31b9268b/d/sha256/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
159+
```

0 commit comments

Comments
 (0)