Skip to content

Commit a5aa967

Browse files
authored
External Storage: add external_storage_runner to handle core extstore logic (#2094)
1 parent 841fd72 commit a5aa967

4 files changed

Lines changed: 581 additions & 0 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/**
2+
* Engine that drives External Storage `store` and `retrieve` operations.
3+
*
4+
* @module
5+
*/
6+
import { temporal } from '@temporalio/proto';
7+
8+
import { StorageDriverClaim } from '../converter/extstore';
9+
import type {
10+
ExternalStorage,
11+
StorageDriver,
12+
StorageDriverRetrieveContext,
13+
StorageDriverStoreContext,
14+
StorageDriverTargetInfo,
15+
} from '../converter/extstore';
16+
import { ValueError } from '../errors';
17+
import type { Payload } from '../interfaces';
18+
import { decodeReferencePayload, encodeReferencePayload, isReferencePayload } from './extstore-helpers';
19+
20+
const PayloadProto = temporal.api.common.v1.Payload;
21+
22+
/** @internal @experimental */
23+
export interface ExternalStoreOptions {
24+
/** Identity of the workflow or activity that produced the payloads. */
25+
target?: StorageDriverTargetInfo;
26+
/** Aborts the in-flight store operation. */
27+
abortSignal?: AbortSignal;
28+
}
29+
30+
/** @internal @experimental */
31+
export interface ExternalRetrieveOptions {
32+
/** Aborts the in-flight retrieve operation. */
33+
abortSignal?: AbortSignal;
34+
}
35+
36+
/**
37+
* Drives External Storage operations against a configured {@link ExternalStorage}.
38+
*
39+
* @internal
40+
* @experimental
41+
*/
42+
export class ExternalStorageRunner {
43+
constructor(private readonly externalStorage: ExternalStorage) {}
44+
45+
/**
46+
* Replace each payload above the configured size threshold with a reference payload.
47+
* Payloads below the threshold (or that the selector keeps inline) pass through
48+
* unchanged. Order is preserved.
49+
*/
50+
async store(payloads: Payload[], options: ExternalStoreOptions = {}): Promise<Payload[]> {
51+
if (payloads.length === 0) return payloads;
52+
53+
const { driverSelector, payloadSizeThreshold } = this.externalStorage;
54+
const { batchSignal, batchController } = makeBatchSignal(options.abortSignal);
55+
const storeCtx: StorageDriverStoreContext = { abortSignal: batchSignal, target: options.target };
56+
57+
interface StoreItem {
58+
index: number;
59+
payload: Payload;
60+
size: number;
61+
}
62+
const driverGroups = new Map<string, { driver: StorageDriver; items: StoreItem[] }>();
63+
64+
for (const [i, payload] of payloads.entries()) {
65+
const size = payloadProtoSize(payload);
66+
if (size < payloadSizeThreshold) continue;
67+
68+
const selected = driverSelector(storeCtx, payload);
69+
if (selected === null) continue;
70+
if (this.externalStorage.getDriver(selected.name) !== selected) {
71+
throw new ValueError(
72+
`Driver '${selected.name}' returned by driverSelector is not registered in ExternalStorage.drivers`
73+
);
74+
}
75+
76+
let group = driverGroups.get(selected.name);
77+
if (group === undefined) {
78+
group = { driver: selected, items: [] };
79+
driverGroups.set(selected.name, group);
80+
}
81+
group.items.push({ index: i, payload, size });
82+
}
83+
84+
if (driverGroups.size === 0) return payloads;
85+
86+
const result = payloads.slice();
87+
await runWithAbortOnFirstError(batchController, [...driverGroups.values()], async (group) => {
88+
const claims = await group.driver.store(
89+
storeCtx,
90+
group.items.map((it) => it.payload)
91+
);
92+
if (claims.length !== group.items.length) {
93+
throw new ValueError(
94+
`Driver '${group.driver.name}' returned ${claims.length} claims for ${group.items.length} payloads`
95+
);
96+
}
97+
for (const [j, claim] of claims.entries()) {
98+
const item = group.items[j]!;
99+
result[item.index] = encodeReferencePayload({
100+
driverName: group.driver.name,
101+
claim,
102+
sizeBytes: item.size,
103+
});
104+
}
105+
});
106+
107+
return result;
108+
}
109+
110+
/**
111+
* Replace each reference payload in `payloads` with the payload bytes returned by the
112+
* named driver. Non-reference payloads are passed through unchanged. Order is preserved.
113+
*/
114+
async retrieve(payloads: Payload[], options: ExternalRetrieveOptions = {}): Promise<Payload[]> {
115+
if (payloads.length === 0) return payloads;
116+
117+
const { batchSignal, batchController } = makeBatchSignal(options.abortSignal);
118+
const retrieveCtx: StorageDriverRetrieveContext = { abortSignal: batchSignal };
119+
120+
interface RetrieveItem {
121+
index: number;
122+
claim: StorageDriverClaim;
123+
}
124+
const driverGroups = new Map<string, { driver: StorageDriver; items: RetrieveItem[] }>();
125+
126+
for (const [i, payload] of payloads.entries()) {
127+
if (!isReferencePayload(payload)) continue;
128+
const decoded = decodeReferencePayload(payload);
129+
const driver = this.externalStorage.getDriver(decoded.driverName);
130+
if (driver === null) {
131+
throw new ValueError(`No driver registered with name '${decoded.driverName}'`);
132+
}
133+
let group = driverGroups.get(decoded.driverName);
134+
if (group === undefined) {
135+
group = { driver, items: [] };
136+
driverGroups.set(decoded.driverName, group);
137+
}
138+
group.items.push({ index: i, claim: new StorageDriverClaim(decoded.claimData) });
139+
}
140+
141+
if (driverGroups.size === 0) return payloads;
142+
143+
const result = payloads.slice();
144+
await runWithAbortOnFirstError(batchController, [...driverGroups.values()], async (group) => {
145+
const retrieved = await group.driver.retrieve(
146+
retrieveCtx,
147+
group.items.map((it) => it.claim)
148+
);
149+
if (retrieved.length !== group.items.length) {
150+
throw new ValueError(
151+
`Driver '${group.driver.name}' returned ${retrieved.length} payloads for ${group.items.length} claims`
152+
);
153+
}
154+
for (const [j, retrievedPayload] of retrieved.entries()) {
155+
const item = group.items[j]!;
156+
result[item.index] = retrievedPayload;
157+
}
158+
});
159+
160+
return result;
161+
}
162+
}
163+
164+
// ============================================================================
165+
// Internal helpers
166+
// ============================================================================
167+
168+
function payloadProtoSize(payload: Payload): number {
169+
return PayloadProto.encode(payload).len;
170+
}
171+
172+
/**
173+
* Builds an internal controller composed with the caller's signal, so a failing
174+
* driver call can abort its siblings.
175+
*/
176+
function makeBatchSignal(abortSignal?: AbortSignal): { batchSignal: AbortSignal; batchController: AbortController } {
177+
const batchController = new AbortController();
178+
const batchSignal = abortSignal ? AbortSignal.any([batchController.signal, abortSignal]) : batchController.signal;
179+
return { batchSignal, batchController };
180+
}
181+
182+
/**
183+
* Run `task(item)` for each item in parallel. As soon as any task rejects,
184+
* signal `controller.abort(reason)` so siblings can cancel mid-flight. Awaits
185+
* all tasks regardless of outcome and re-throws the first rejection.
186+
*/
187+
async function runWithAbortOnFirstError<T>(
188+
controller: AbortController,
189+
items: T[],
190+
task: (item: T) => Promise<void>
191+
): Promise<void> {
192+
const promises = items.map(task);
193+
for (const p of promises) {
194+
p.catch((reason: unknown) => {
195+
if (!controller.signal.aborted) controller.abort(reason);
196+
});
197+
}
198+
const settled = await Promise.allSettled(promises);
199+
for (const outcome of settled) {
200+
if (outcome.status === 'rejected') throw outcome.reason;
201+
}
202+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './codec-helpers';
77
export * from './codec-types';
88
export * from './data-converter-helpers';
99
export * from './extstore-helpers';
10+
export * from './external-storage-runner';
1011
export * from './parse-host-uri';
1112
export * from './proxy-config';
1213
export * from './tls-config';
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Shared test double for {@link StorageDriver}. By default it stores and retrieves
3+
* payloads via an in-memory `Map`, so a store→retrieve round-trip yields the original
4+
* bytes. Tests that need failure or unusual-shape responses can override individual
5+
* operations via `onStore` / `onRetrieve`.
6+
*
7+
* Every call is recorded on `storeCalls` / `retrieveCalls` (the recording happens
8+
* before the override runs, so an override can introspect its own call).
9+
*/
10+
import type { Payload } from '@temporalio/common';
11+
import {
12+
type StorageDriver,
13+
StorageDriverClaim,
14+
type StorageDriverRetrieveContext,
15+
type StorageDriverStoreContext,
16+
} from '@temporalio/common/lib/converter/extstore';
17+
18+
export interface FakeDriver extends StorageDriver {
19+
storeCalls: { context: StorageDriverStoreContext; payloads: Payload[] }[];
20+
retrieveCalls: { context: StorageDriverRetrieveContext; claims: StorageDriverClaim[] }[];
21+
}
22+
23+
export interface FakeDriverOptions {
24+
/** Driver name. Defaults to `'mem'`. */
25+
name?: string;
26+
/** Override default in-memory store. */
27+
onStore?: (payloads: Payload[]) => StorageDriverClaim[] | Promise<StorageDriverClaim[]>;
28+
/** Override default in-memory retrieve. */
29+
onRetrieve?: (claims: StorageDriverClaim[]) => Payload[] | Promise<Payload[]>;
30+
}
31+
32+
export function makeFakeDriver(opts: FakeDriverOptions = {}): FakeDriver {
33+
const name = opts.name ?? 'mem';
34+
const memory = new Map<string, Payload>();
35+
let nextId = 0;
36+
const driver: FakeDriver = {
37+
name,
38+
type: name,
39+
storeCalls: [],
40+
retrieveCalls: [],
41+
async store(context, payloads) {
42+
driver.storeCalls.push({ context, payloads });
43+
if (opts.onStore) return opts.onStore(payloads);
44+
return payloads.map((p) => {
45+
const id = `${name}-${nextId++}`;
46+
memory.set(id, p);
47+
return new StorageDriverClaim({ id });
48+
});
49+
},
50+
async retrieve(context, claims) {
51+
driver.retrieveCalls.push({ context, claims });
52+
if (opts.onRetrieve) return opts.onRetrieve(claims);
53+
return claims.map((c) => memory.get(c.claimData.id!)!);
54+
},
55+
};
56+
return driver;
57+
}

0 commit comments

Comments
 (0)