Skip to content

Commit 3e94634

Browse files
authored
External Storage: foundational types and config (#2092)
1 parent 0ba96e2 commit 3e94634

8 files changed

Lines changed: 427 additions & 1 deletion

File tree

CONTRIBUTING.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ rm -rf ./example "$TMP_DIR"
162162

163163
- Typescript code is linted with [eslint](https://eslint.org/)
164164
- Files in this repo are formatted with [prettier](https://prettier.io/)
165+
- Prefer explicit named re-exports and avoid wildcard re-exports where possible (`export * from ...`) in public entrypoint / barrel files.
166+
- Use `@experimental` and `@internal` to manage API stability and visibility. Mark new or work-in-progress exported APIs `@experimental` to signal their shape may still change. Mark a symbol `@internal` to keep it out of the generated public docs. The two are independent and may be combined. It is fine to ship something `@internal` now and promote it to public later, by removing `@internal` and adding a named re-export, once it is actually usable. The reverse is a breaking change, so prefer starting narrow.
165167
- Pull request titles SHOULD adhere to the [Conventional Commits specification](https://conventionalcommits.org/), for example:
166168

167169
```

packages/common/src/converter/data-converter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export interface LoadedDataConverter {
6565
/**
6666
* The default {@link FailureConverter} used by the SDK.
6767
*
68-
* Error messages and stack traces are serizalized as plain text.
68+
* Error messages and stack traces are serialized as plain text.
6969
*/
7070
export const defaultFailureConverter: FailureConverter = new DefaultFailureConverter();
7171

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { ValueError } from '../errors';
2+
import type { Payload } from '../interfaces';
3+
4+
/**
5+
* Reference returned from {@link StorageDriver.store}. `claimData` is an
6+
* opaque key/value map the driver uses to retrieve the payload later.
7+
*
8+
* @internal
9+
* @experimental
10+
*/
11+
export class StorageDriverClaim {
12+
constructor(readonly claimData: Record<string, string>) {}
13+
}
14+
15+
/**
16+
* Workflow identity information passed to a storage driver.
17+
*
18+
* @internal
19+
* @experimental
20+
*/
21+
export interface StorageDriverWorkflowInfo {
22+
readonly kind: 'workflow';
23+
/** The namespace of the workflow execution. */
24+
readonly namespace: string;
25+
/** The workflow ID. */
26+
readonly id?: string;
27+
/** The workflow run ID, if available. */
28+
readonly runId?: string;
29+
/** The workflow type name, if available. */
30+
readonly type?: string;
31+
}
32+
33+
/**
34+
* Activity identity information passed to a storage driver.
35+
*
36+
* @internal
37+
* @experimental
38+
*/
39+
export interface StorageDriverActivityInfo {
40+
readonly kind: 'activity';
41+
/** The namespace of the activity execution. */
42+
readonly namespace: string;
43+
/** The activity ID. */
44+
readonly id?: string;
45+
/** The activity run ID (only for standalone activities). */
46+
readonly runId?: string;
47+
/** The activity type name, if available. */
48+
readonly type?: string;
49+
}
50+
51+
/**
52+
* Identity of the workflow or activity that produced the payloads being stored.
53+
*
54+
* @internal
55+
* @experimental
56+
*/
57+
export type StorageDriverTargetInfo = StorageDriverWorkflowInfo | StorageDriverActivityInfo;
58+
59+
/**
60+
* Context handed to {@link StorageDriver.store} (and to the selector).
61+
*
62+
* @internal
63+
* @experimental
64+
*/
65+
export interface StorageDriverStoreContext {
66+
/** Aborts the in-flight operation; siblings are cancelled on first error. */
67+
abortSignal?: AbortSignal;
68+
/** Identity of the workflow / activity that produced the payloads. */
69+
target?: StorageDriverTargetInfo;
70+
}
71+
72+
/**
73+
* Context handed to {@link StorageDriver.retrieve}.
74+
*
75+
* @internal
76+
* @experimental
77+
*/
78+
export interface StorageDriverRetrieveContext {
79+
abortSignal?: AbortSignal;
80+
}
81+
82+
/**
83+
* External storage backend driver.
84+
*
85+
* `name` is the per-instance routing key written into the wire format.
86+
* `type` is a stable cross-language driver-implementation identifier
87+
* reported via worker heartbeat (e.g. `"aws.s3driver"`).
88+
*
89+
* @internal
90+
* @experimental
91+
*/
92+
export interface StorageDriver {
93+
readonly name: string;
94+
readonly type: string;
95+
store(context: StorageDriverStoreContext, payloads: Payload[]): Promise<StorageDriverClaim[]>;
96+
retrieve(context: StorageDriverRetrieveContext, claims: StorageDriverClaim[]): Promise<Payload[]>;
97+
}
98+
99+
/**
100+
* User-supplied function that picks the destination driver for a given
101+
* payload, or returns `null` to keep the payload inline.
102+
*
103+
* @internal
104+
* @experimental
105+
*/
106+
export type StorageDriverSelector = (context: StorageDriverStoreContext, payload: Payload) => StorageDriver | null;
107+
108+
// ============================================================================
109+
// Configuration
110+
// ============================================================================
111+
112+
/** Default {@link ExternalStorage.payloadSizeThreshold}: 256 KiB. */
113+
const DEFAULT_PAYLOAD_SIZE_THRESHOLD = 256 * 1024;
114+
115+
/**
116+
* Configuration for external storage. Holds the registered drivers, an
117+
* optional selector, and the size threshold above which payloads are
118+
* eligible for offloading to external storage. A selector function is
119+
* required when more than one driver is registered.
120+
*
121+
* @internal
122+
* @experimental
123+
*/
124+
export class ExternalStorage {
125+
readonly drivers: StorageDriver[];
126+
/**
127+
* Selects the destination driver for each payload, or returns `null` to keep
128+
* the payload inline.
129+
*/
130+
readonly driverSelector: StorageDriverSelector;
131+
readonly payloadSizeThreshold: number;
132+
private readonly driversByName: ReadonlyMap<string, StorageDriver>;
133+
134+
constructor({
135+
drivers,
136+
driverSelector,
137+
payloadSizeThreshold = DEFAULT_PAYLOAD_SIZE_THRESHOLD,
138+
}: {
139+
drivers: StorageDriver[];
140+
driverSelector?: StorageDriverSelector;
141+
/** Omit for default (256 KiB). Set `0` to consider all payloads regardless of size. */
142+
payloadSizeThreshold?: number;
143+
}) {
144+
if (!Array.isArray(drivers) || drivers.length === 0) {
145+
throw new ValueError('ExternalStorage requires at least one driver');
146+
}
147+
if (
148+
typeof payloadSizeThreshold !== 'number' ||
149+
!Number.isFinite(payloadSizeThreshold) ||
150+
payloadSizeThreshold < 0
151+
) {
152+
throw new ValueError(
153+
`ExternalStorage.payloadSizeThreshold must be a non-negative finite number, got ${String(payloadSizeThreshold)}`
154+
);
155+
}
156+
157+
const driversByName = new Map<string, StorageDriver>();
158+
for (const driver of drivers) {
159+
if (typeof driver?.name !== 'string' || driver.name.length === 0) {
160+
throw new ValueError("Storage driver 'name' must be a non-empty string");
161+
}
162+
if (driversByName.has(driver.name)) {
163+
throw new ValueError(`Duplicate storage driver name: '${driver.name}'`);
164+
}
165+
driversByName.set(driver.name, driver);
166+
}
167+
168+
if (driverSelector === undefined && driversByName.size > 1) {
169+
throw new ValueError('ExternalStorage.driverSelector is required when more than one driver is registered');
170+
}
171+
172+
this.drivers = [...drivers];
173+
this.driverSelector = driverSelector ?? (() => drivers[0] as StorageDriver);
174+
this.payloadSizeThreshold = payloadSizeThreshold;
175+
this.driversByName = driversByName;
176+
}
177+
178+
/** Look up a registered driver by name. Returns `null` if no driver with that name is registered. */
179+
getDriver(name: string): StorageDriver | null {
180+
return this.driversByName.get(name) ?? null;
181+
}
182+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import Long from 'long';
2+
import * as proto from '@temporalio/proto';
3+
import { decode } from '../encoding';
4+
import { ValueError } from '../errors';
5+
import type { Payload } from '../interfaces';
6+
import { ProtobufJsonPayloadConverter } from '../converter/protobuf-payload-converters';
7+
import { encodingTypes, METADATA_ENCODING_KEY, METADATA_MESSAGE_TYPE_KEY } from '../converter/types';
8+
import type { StorageDriverClaim } from '../converter/extstore';
9+
10+
const ExternalStorageReferenceProto = proto.temporal.api.sdk.v1.ExternalStorageReference;
11+
const PayloadProto = proto.temporal.api.common.v1.Payload;
12+
const storageReferenceConverter = new ProtobufJsonPayloadConverter(proto);
13+
14+
const EXTSTORE_REFERENCE_MESSAGE_TYPE = 'temporal.api.sdk.v1.ExternalStorageReference';
15+
const EXTSTORE_REFERENCE_ENCODING = encodingTypes.METADATA_ENCODING_PROTOBUF_JSON;
16+
17+
/**
18+
* True if the payload is an External Storage reference:
19+
* `temporal.api.sdk.v1.ExternalStorageReference`.
20+
*
21+
* @internal
22+
* @experimental
23+
*/
24+
export function isReferencePayload(payload: Payload): boolean {
25+
const encodingValue = readMetadataString(payload, METADATA_ENCODING_KEY);
26+
if (encodingValue === EXTSTORE_REFERENCE_ENCODING) {
27+
return readMetadataString(payload, METADATA_MESSAGE_TYPE_KEY) === EXTSTORE_REFERENCE_MESSAGE_TYPE;
28+
}
29+
return false;
30+
}
31+
32+
/**
33+
* Parsed contents of a reference payload.
34+
*
35+
* @internal
36+
* @experimental
37+
*/
38+
export interface DecodedReferencePayload {
39+
driverName: string;
40+
claimData: Record<string, string>;
41+
sizeBytes: number;
42+
}
43+
44+
/**
45+
* Decode a reference payload from the wire format {@link ExternalStorageReference}.
46+
* Throws {@link ValueError} if the payload is not a reference or is malformed.
47+
* Callers should gate on {@link isReferencePayload} first.
48+
*
49+
* @internal
50+
* @experimental
51+
*/
52+
export function decodeReferencePayload(payload: Payload): DecodedReferencePayload {
53+
const ref = storageReferenceConverter.fromPayload<proto.temporal.api.sdk.v1.IExternalStorageReference>(payload);
54+
if (!ref.driverName) {
55+
// TODO: use the new stable error codes here
56+
throw new ValueError("Reference payload field 'driverName' must be a non-empty string");
57+
}
58+
return {
59+
driverName: ref.driverName,
60+
claimData: { ...ref.claimData },
61+
sizeBytes: readSizeBytes(payload),
62+
};
63+
}
64+
65+
/**
66+
* Encode a reference payload to wire format.
67+
*
68+
* @internal
69+
* @experimental
70+
*/
71+
export function encodeReferencePayload({
72+
driverName,
73+
claim,
74+
sizeBytes,
75+
}: {
76+
driverName: string;
77+
claim: StorageDriverClaim;
78+
sizeBytes: number;
79+
}): Payload {
80+
const ref = ExternalStorageReferenceProto.create({ driverName, claimData: claim.claimData });
81+
const payload = storageReferenceConverter.toPayload(ref);
82+
if (payload === undefined) {
83+
// TODO: use the new stable error codes here
84+
throw new ValueError('Failed to serialize ExternalStorageReference');
85+
}
86+
return {
87+
...payload,
88+
externalPayloads: [PayloadProto.ExternalPayloadDetails.create({ sizeBytes: Long.fromNumber(sizeBytes) })],
89+
};
90+
}
91+
92+
function readMetadataString(payload: Payload, key: string): string | undefined {
93+
const raw = payload.metadata?.[key];
94+
if (!raw) return undefined;
95+
return decode(raw);
96+
}
97+
98+
function readSizeBytes(payload: Payload): number {
99+
const details = payload.externalPayloads?.[0];
100+
if (!details?.sizeBytes) return 0;
101+
return Long.isLong(details.sizeBytes) ? details.sizeBytes.toNumber() : Number(details.sizeBytes);
102+
}

packages/common/src/internal-non-workflow/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
export * from './codec-helpers';
77
export * from './codec-types';
88
export * from './data-converter-helpers';
9+
export * from './extstore-helpers';
910
export * from './parse-host-uri';
1011
export * from './proxy-config';
1112
export * from './tls-config';

packages/proto/scripts/compile-proto.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ async function main() {
8787
'temporal/api/cloud/cloudservice/v1/service.proto',
8888
'temporal/api/errordetails/v1/message.proto',
8989
'temporal/api/sdk/v1/workflow_metadata.proto',
90+
'temporal/api/sdk/v1/external_storage.proto',
9091
'temporal/api/testservice/v1/request_response.proto',
9192
'temporal/api/testservice/v1/service.proto',
9293
'grpc/health/v1/health.proto',

0 commit comments

Comments
 (0)