Skip to content

Commit 041d6f1

Browse files
committed
feat(extstore): Add extstore store/retrieve to pipeline via LoadedDataConverter. Later, we can add ExternalStorage config to DataConverter to 'enable' it.
1 parent f5fb192 commit 041d6f1

7 files changed

Lines changed: 179 additions & 5 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ExternalStorage } from './extstore';
12
import type { FailureConverter } from './failure-converter';
23
import { DefaultFailureConverter } from './failure-converter';
34
import type { PayloadCodec } from './payload-codec';
@@ -60,6 +61,12 @@ export interface LoadedDataConverter {
6061
payloadConverter: PayloadConverter;
6162
failureConverter: FailureConverter;
6263
payloadCodecs: PayloadCodec[];
64+
65+
/**
66+
* @internal
67+
* @experimental
68+
*/
69+
externalStorage?: ExternalStorage;
6370
}
6471

6572
/**
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Visits payloads for External Storage and applies payload transformations via
3+
* {@link ExternalStorageRunner}.
4+
*
5+
* @module
6+
*/
7+
import type { coresdk } from '@temporalio/proto';
8+
9+
import type { ConcurrencyLimit } from '../concurrency/limit';
10+
import type { ExternalStorage, StorageDriverTargetInfo } from '../converter/extstore';
11+
import { ExternalStorageRunner } from './external-storage-runner';
12+
import { visitWorkflowActivation, visitWorkflowActivationCompletion } from './payload-visitor';
13+
14+
/**
15+
* Offload every eligible payload in a `WorkflowActivationCompletion` to external storage,
16+
* replacing it in place with a reference payload.
17+
*
18+
* @internal
19+
* @experimental
20+
*/
21+
export function storeWorkflowActivationCompletion(
22+
externalStorage: ExternalStorage,
23+
completion: coresdk.workflow_completion.IWorkflowActivationCompletion,
24+
{
25+
target,
26+
limit,
27+
abortSignal,
28+
}: {
29+
/** Identity of the workflow / activity that produced the payloads, used for object naming. */
30+
target?: StorageDriverTargetInfo;
31+
/** Bounds concurrent transform calls across payload sites. Omit for sequential. */
32+
limit?: ConcurrencyLimit;
33+
/** Aborts the walk and every in-flight driver call. */
34+
abortSignal?: AbortSignal;
35+
} = {}
36+
): Promise<void> {
37+
const runner = new ExternalStorageRunner(externalStorage);
38+
return visitWorkflowActivationCompletion(completion, {
39+
transformPayloads: (payloads, _context, signal) => runner.store(payloads, { target, abortSignal: signal }),
40+
transformPayload: (payload, _context, signal) =>
41+
runner.store([payload], { target, abortSignal: signal }).then((stored) => stored[0]!),
42+
limit,
43+
abortSignal,
44+
});
45+
}
46+
47+
/**
48+
* Replace every reference payload in a `WorkflowActivation` with the payload bytes fetched
49+
* from external storage, in place.
50+
*
51+
* @internal
52+
* @experimental
53+
*/
54+
export function retrieveWorkflowActivation(
55+
externalStorage: ExternalStorage,
56+
activation: coresdk.workflow_activation.IWorkflowActivation,
57+
{ limit, abortSignal }: { limit?: ConcurrencyLimit; abortSignal?: AbortSignal } = {}
58+
): Promise<void> {
59+
const runner = new ExternalStorageRunner(externalStorage);
60+
return visitWorkflowActivation(activation, {
61+
transformPayloads: (payloads, _context, signal) => runner.retrieve(payloads, { abortSignal: signal }),
62+
transformPayload: (payload, _context, signal) =>
63+
runner.retrieve([payload], { abortSignal: signal }).then((retrieved) => retrieved[0]!),
64+
limit,
65+
abortSignal,
66+
});
67+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export * from './codec-types';
88
export * from './data-converter-helpers';
99
export * from './extstore-helpers';
1010
export * from './external-storage-runner';
11+
export * from './external-storage-visitor';
1112
export * from './parse-host-uri';
1213
export * from './proxy-config';
1314
export * from './tls-config';
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/* eslint @typescript-eslint/no-non-null-assertion: 0 */
2+
import test from 'ava';
3+
import type { Payload } from '@temporalio/common';
4+
import { ExternalStorage } from '@temporalio/common/lib/converter/extstore';
5+
import {
6+
isReferencePayload,
7+
retrieveWorkflowActivation,
8+
storeWorkflowActivationCompletion,
9+
} from '@temporalio/common/lib/internal-non-workflow';
10+
import { encode } from '@temporalio/common/lib/encoding';
11+
import { METADATA_ENCODING_KEY } from '@temporalio/common/lib/converter/types';
12+
import { coresdk } from '@temporalio/proto';
13+
import { makeFakeDriver } from './extstore-fake-driver';
14+
15+
/** Build a Payload whose proto-encoded size is at least `bodyBytes`, filled with `fill`. */
16+
function makePayload(bodyBytes: number, fill = 0): Payload {
17+
return {
18+
metadata: { [METADATA_ENCODING_KEY]: encode('binary/plain') },
19+
data: new Uint8Array(bodyBytes).fill(fill),
20+
};
21+
}
22+
23+
function externalStorageWith(driver = makeFakeDriver({ name: 's3' }), payloadSizeThreshold = 96) {
24+
return { driver, externalStorage: new ExternalStorage({ drivers: [driver], payloadSizeThreshold }) };
25+
}
26+
27+
const WORKFLOW_TARGET = { kind: 'workflow', namespace: 'ns', id: 'wf-1', runId: 'run-1' } as const;
28+
29+
test('store offloads a large payload at a singular site and threads the target to the driver', async (t) => {
30+
const { driver, externalStorage } = externalStorageWith();
31+
const completion: coresdk.workflow_completion.IWorkflowActivationCompletion = {
32+
successful: { commands: [{ completeWorkflowExecution: { result: makePayload(256) } }] },
33+
};
34+
35+
await storeWorkflowActivationCompletion(externalStorage, completion, { target: WORKFLOW_TARGET });
36+
37+
const result = completion.successful!.commands![0]!.completeWorkflowExecution!.result!;
38+
t.true(isReferencePayload(result));
39+
t.is(driver.storeCalls.length, 1);
40+
t.deepEqual(driver.storeCalls[0]!.context.target, WORKFLOW_TARGET);
41+
});
42+
43+
test('store offloads only above-threshold payloads at a repeated site', async (t) => {
44+
const { externalStorage } = externalStorageWith();
45+
const small = makePayload(0);
46+
const completion: coresdk.workflow_completion.IWorkflowActivationCompletion = {
47+
successful: { commands: [{ scheduleActivity: { arguments: [makePayload(256), small] } }] },
48+
};
49+
50+
await storeWorkflowActivationCompletion(externalStorage, completion, { target: WORKFLOW_TARGET });
51+
52+
const args = completion.successful!.commands![0]!.scheduleActivity!.arguments!;
53+
t.true(isReferencePayload(args[0]!));
54+
t.deepEqual(args[1], small);
55+
});
56+
57+
test('store then retrieve round-trips the original payload bytes through a worker message', async (t) => {
58+
const { driver, externalStorage } = externalStorageWith();
59+
const original = makePayload(256, 7);
60+
const completion: coresdk.workflow_completion.IWorkflowActivationCompletion = {
61+
successful: { commands: [{ completeWorkflowExecution: { result: original } }] },
62+
};
63+
64+
await storeWorkflowActivationCompletion(externalStorage, completion, { target: WORKFLOW_TARGET });
65+
const reference = completion.successful!.commands![0]!.completeWorkflowExecution!.result!;
66+
t.true(isReferencePayload(reference));
67+
68+
// The reference produced on the outbound path arrives back on a later inbound activation.
69+
const activation: coresdk.workflow_activation.IWorkflowActivation = {
70+
jobs: [{ resolveActivity: { result: { completed: { result: reference } } } }],
71+
};
72+
await retrieveWorkflowActivation(externalStorage, activation);
73+
74+
const retrieved = activation.jobs![0]!.resolveActivity!.result!.completed!.result!;
75+
t.false(isReferencePayload(retrieved));
76+
t.deepEqual(retrieved, original);
77+
t.is(driver.retrieveCalls.length, 1);
78+
});

packages/test/src/test-workflow-codec-runner.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ function failureWithDetail(label: string) {
2020
);
2121
}
2222

23-
function decodeCompletion(bytes: Uint8Array): coresdk.workflow_completion.WorkflowActivationCompletion {
23+
function decodeCompletion(
24+
completion: coresdk.workflow_completion.IWorkflowActivationCompletion
25+
): coresdk.workflow_completion.WorkflowActivationCompletion {
26+
const bytes = coresdk.workflow_completion.WorkflowActivationCompletion.encodeDelimited(completion).finish();
2427
return coresdk.workflow_completion.WorkflowActivationCompletion.decodeDelimited(bytes);
2528
}
2629

packages/worker/src/worker.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import {
3131
decodeFromPayloadsAtIndex,
3232
encodeErrorToFailure,
3333
encodeToPayload,
34+
retrieveWorkflowActivation,
35+
storeWorkflowActivationCompletion,
3436
} from '@temporalio/common/lib/internal-non-workflow';
3537
import { historyFromJSON } from '@temporalio/common/lib/proto-utils';
3638
import type { Duration } from '@temporalio/common/lib/time';
@@ -1441,6 +1443,10 @@ export class Worker {
14411443
workflowId,
14421444
});
14431445
}
1446+
const { externalStorage } = this.options.loadedDataConverter;
1447+
if (externalStorage) {
1448+
await retrieveWorkflowActivation(externalStorage, activation);
1449+
}
14441450
const decodedActivation = await workflowCodecRunner.decodeActivation(activation);
14451451

14461452
if (workflow === undefined) {
@@ -1456,7 +1462,19 @@ export class Worker {
14561462
let isFatalError = false;
14571463
try {
14581464
const unencodedCompletion = await workflow.workflow.activate(decodedActivation);
1459-
const completion = await workflowCodecRunner.encodeCompletion(unencodedCompletion);
1465+
const encodedCompletion = await workflowCodecRunner.encodeCompletion(unencodedCompletion);
1466+
if (externalStorage) {
1467+
await storeWorkflowActivationCompletion(externalStorage, encodedCompletion, {
1468+
target: {
1469+
kind: 'workflow',
1470+
namespace: workflowCodecRunner.workflowContext.namespace,
1471+
id: workflowCodecRunner.workflowContext.workflowId,
1472+
runId: activation.runId,
1473+
},
1474+
});
1475+
}
1476+
const completion =
1477+
coresdk.workflow_completion.WorkflowActivationCompletion.encodeDelimited(encodedCompletion).finish();
14601478

14611479
return { state: workflow, output: { close, completion } };
14621480
} catch (err) {

packages/worker/src/workflow-codec-runner.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export class WorkflowCodecRunner {
3434

3535
constructor(
3636
private readonly codecs: PayloadCodec[],
37-
private readonly workflowContext: WorkflowSerializationContext
37+
public readonly workflowContext: WorkflowSerializationContext
3838
) {}
3939

4040
private consumeContext<TContext extends SerializationContext>(
@@ -368,7 +368,7 @@ export class WorkflowCodecRunner {
368368
*/
369369
public async encodeCompletion(
370370
completion: coresdk.workflow_completion.IWorkflowActivationCompletion
371-
): Promise<Uint8Array> {
371+
): Promise<Encoded<coresdk.workflow_completion.IWorkflowActivationCompletion>> {
372372
const encodedCompletion: Encoded<coresdk.workflow_completion.IWorkflowActivationCompletion> = {
373373
...completion,
374374
failed: completion.failed
@@ -608,6 +608,6 @@ export class WorkflowCodecRunner {
608608
: null,
609609
};
610610

611-
return coresdk.workflow_completion.WorkflowActivationCompletion.encodeDelimited(encodedCompletion).finish();
611+
return encodedCompletion;
612612
}
613613
}

0 commit comments

Comments
 (0)