Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

### New

- Add payload storage contracts and bounded protobuf payload processing, with inclusive UTF-8
thresholds, explicit storage/size failures, and non-retriable worker upload-failure conversion.
This is phase 1 of the external-payload feature; transport, providers, and cleanup follow separately.
Reserve core `0.4.1` for the new APIs; do not publish partial feature phases.
- Expose readonly `OrchestrationContext.sourceInstanceId` from the existing protobuf
request properties on initial execution and replay, independently of parent identity.
Strings are preserved verbatim; null or missing metadata returns `undefined`.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/azure-functions-durable/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"dependencies": {
"@azure/functions": "^4.16.1",
"@grpc/grpc-js": "^1.14.4",
"@microsoft/durabletask-js": "0.4.0"
"@microsoft/durabletask-js": "0.4.1"
},
"peerDependencies": {
"@azure/identity": "^4.0.0"
Expand Down
2 changes: 1 addition & 1 deletion packages/durabletask-js/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@microsoft/durabletask-js",
"version": "0.4.0",
"version": "0.4.1",
"description": "A Durable Task Javascript SDK compatible with Dapr Workflow and its underlying Durable Task engine",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
6 changes: 6 additions & 0 deletions packages/durabletask-js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
// Licensed under the MIT License.

// Client and Worker
export {
PayloadStore,
PayloadStorageOptions,
PayloadStorageException,
PayloadDeleteOutcome,
} from "./payloads/payload-store";
export { TaskHubGrpcClient, TaskHubGrpcClientOptions, MetadataGenerator } from "./client/client";
export { TaskHubGrpcWorker, TaskHubGrpcWorkerOptions } from "./worker/task-hub-grpc-worker";
export { ConcurrencyOptions } from "./worker/concurrency-options";
Expand Down
196 changes: 196 additions & 0 deletions packages/durabletask-js/src/payloads/payload-processor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { StringValue } from "google-protobuf/google/protobuf/wrappers_pb";
import * as pb from "../proto/orchestrator_service_pb";
import { newFailureDetails } from "../utils/pb-helper.util";
import { PayloadStorageException, PayloadStorageOptions } from "./payload-store";
import { runBounded } from "./run-bounded";

/** Explicit protobuf field mapping, aligned with the .NET AzureBlobPayloads SideCar interceptor. */
export class PayloadProcessor {
private readonly _threshold: number;
private readonly _maximum: number;

constructor(private readonly _options: PayloadStorageOptions) {
this._threshold = _options.thresholdBytes ?? 256 * 1024;
this._maximum = _options.maxPayloadBytes ?? 10 * 1024 * 1024;
if (!Number.isSafeInteger(this._threshold) || this._threshold < 0 || this._threshold > 1024 * 1024) {
throw new RangeError("thresholdBytes must be an integer between 0 and 1 MiB.");
}
if (!Number.isSafeInteger(this._maximum) || this._maximum <= 0) {
throw new RangeError("maxPayloadBytes must be a positive safe integer.");
}
if (!_options.store) throw new TypeError("A payload store is required.");
}

async externalize(message: unknown, signal?: AbortSignal): Promise<void> {
const fields: (StringValue | undefined)[] = [];
if (
message instanceof pb.CreateInstanceRequest ||
message instanceof pb.RaiseEventRequest ||
message instanceof pb.SignalEntityRequest
) {
fields.push(message.getInput());
} else if (message instanceof pb.TerminateRequest) {
fields.push(message.getOutput());
} else if (
message instanceof pb.SuspendRequest ||
message instanceof pb.ResumeRequest ||
message instanceof pb.RewindInstanceRequest
) {
fields.push(message.getReason());
} else if (message instanceof pb.ActivityResponse) {
fields.push(message.getResult());
} else if (message instanceof pb.OrchestratorResponse) {
fields.push(message.getCustomstatus());
for (const action of message.getActionsList()) {
fields.push(
action.getScheduletask()?.getInput(),
action.getCreatesuborchestration()?.getInput(),
action.getCompleteorchestration()?.getResult(),
action.getCompleteorchestration()?.getDetails(),
action.getTerminateorchestration()?.getReason(),
action.getSendevent()?.getData(),
action.getSendentitymessage()?.getEntityoperationsignaled()?.getInput(),
action.getSendentitymessage()?.getEntityoperationcalled()?.getInput(),
);
for (const event of action.getRewindorchestration()?.getNewhistoryList() ?? []) {
this._historyFields(event, fields);
}
}
} else if (message instanceof pb.EntityBatchResult) {
fields.push(message.getEntitystate());
for (const result of message.getResultsList()) fields.push(result.getSuccess()?.getResult());
for (const action of message.getActionsList()) {
fields.push(action.getSendsignal()?.getInput(), action.getStartneworchestration()?.getInput());
}
} else if (message instanceof pb.EntityBatchRequest) {
fields.push(message.getEntitystate());
for (const operation of message.getOperationsList()) fields.push(operation.getInput());
} else if (message instanceof pb.EntityRequest) {
fields.push(message.getEntitystate());
}
const operations: (() => Promise<void>)[] = [];
for (const field of fields) {
const value = field?.getValue();
if (!field || !value) continue;
const size = Buffer.byteLength(value, "utf8");
if (size < this._threshold) continue;
operations.push(async () => {
if (size > this._maximum) {
throw new PayloadStorageException(
`Payload size ${size} bytes exceeds the configured maximum of ${this._maximum} bytes.`,
);
}
field.setValue(await this._options.store.upload(value, signal));
});
}
try {
await runBounded(operations, signal);
} catch (error) {
if (
!isPermanentStorageFailure(error) ||
!(message instanceof pb.ActivityResponse || message instanceof pb.OrchestratorResponse)
)
throw error;
const details = newFailureDetails(error);
details.setIsnonretriable(true);
if (message instanceof pb.ActivityResponse) {
message.clearResult();
message.setFailuredetails(details);
} else {
message.clearCustomstatus();
message.setIspartial(false);
message.clearChunkindex();
message.setActionsList([
new pb.OrchestratorAction().setCompleteorchestration(
new pb.CompleteOrchestrationAction()
.setOrchestrationstatus(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED)
.setFailuredetails(details),
),
]);
}
}
}

async resolve(message: unknown, signal?: AbortSignal): Promise<void> {
const fields: (StringValue | undefined)[] = [];
this._responseFields(message, fields);
const operations: (() => Promise<void>)[] = [];
for (const field of fields) {
const value = field?.getValue();
if (field && value && this._options.store.isKnownPayloadToken(value)) {
operations.push(async () => {
field.setValue(await this._options.store.download(value, signal));
});
}
}
await runBounded(operations, signal);
}

private _responseFields(message: unknown, fields: (StringValue | undefined)[]): void {
if (message instanceof pb.GetInstanceResponse) {
this._stateFields(message.getOrchestrationstate(), fields);
} else if (message instanceof pb.QueryInstancesResponse) {
for (const state of message.getOrchestrationstateList()) this._stateFields(state, fields);
} else if (message instanceof pb.HistoryChunk) {
for (const event of message.getEventsList()) this._historyFields(event, fields);
} else if (message instanceof pb.GetEntityResponse) {
fields.push(message.getEntity()?.getSerializedstate());
} else if (message instanceof pb.QueryEntitiesResponse) {
for (const entity of message.getEntitiesList()) fields.push(entity.getSerializedstate());
} else if (message instanceof pb.WorkItem) {
this._responseFields(message.getActivityrequest(), fields);
this._responseFields(message.getOrchestratorrequest(), fields);
this._responseFields(message.getEntityrequest(), fields);
this._responseFields(message.getEntityrequestv2(), fields);
} else if (message instanceof pb.ActivityRequest) {
fields.push(message.getInput());
} else if (message instanceof pb.OrchestratorRequest) {
for (const event of [...message.getPasteventsList(), ...message.getNeweventsList()]) {
this._historyFields(event, fields);
}
} else if (message instanceof pb.EntityBatchRequest) {
fields.push(message.getEntitystate());
for (const operation of message.getOperationsList()) fields.push(operation.getInput());
} else if (message instanceof pb.EntityRequest) {
fields.push(message.getEntitystate());
for (const event of message.getOperationrequestsList()) this._historyFields(event, fields);
}
}

private _stateFields(state: pb.OrchestrationState | undefined, fields: (StringValue | undefined)[]): void {
if (state) fields.push(state.getInput(), state.getOutput(), state.getCustomstatus());
}

private _historyFields(event: pb.HistoryEvent, fields: (StringValue | undefined)[]): void {
fields.push(
event.getExecutionstarted()?.getInput(),
event.getExecutioncompleted()?.getResult(),
event.getExecutionterminated()?.getInput(),
event.getTaskscheduled()?.getInput(),
event.getTaskcompleted()?.getResult(),
event.getSuborchestrationinstancecreated()?.getInput(),
event.getSuborchestrationinstancecompleted()?.getResult(),
event.getEventsent()?.getInput(),
event.getEventraised()?.getInput(),
event.getGenericevent()?.getData(),
event.getContinueasnew()?.getInput(),
event.getExecutionsuspended()?.getInput(),
event.getExecutionresumed()?.getInput(),
event.getExecutionrewound()?.getReason(),
event.getEntityoperationsignaled()?.getInput(),
event.getEntityoperationcalled()?.getInput(),
event.getEntityoperationcompleted()?.getOutput(),
);
this._stateFields(event.getHistorystate()?.getOrchestrationstate(), fields);
}
}

export function isPermanentStorageFailure(error: unknown): error is Error {
if (error instanceof PayloadStorageException) return true;
if (!(error instanceof Error) || !("statusCode" in error)) return false;
const status = error.statusCode;
return typeof status === "number" && status >= 400 && status < 500 && status !== 408 && status !== 429;
}
39 changes: 39 additions & 0 deletions packages/durabletask-js/src/payloads/payload-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

/** Confirmed results of a conditional, ownership-checked payload deletion. */
export enum PayloadDeleteOutcome {
Unspecified = 0,
Deleted = 1,
AlreadyAbsent = 2,
NotStoreOwned = 3,
}

/** Out-of-band storage for serialized UTF-8 payloads. Implementations must honor cancellation. */
export abstract class PayloadStore {
abstract upload(payload: string, signal?: AbortSignal): Promise<string>;
abstract download(token: string, signal?: AbortSignal): Promise<string>;
abstract isKnownPayloadToken(value: string): boolean;

/** Optional cleanup support. Never delete a blob without verifying ownership and its ETag. */
async delete(_token: string, _signal?: AbortSignal): Promise<PayloadDeleteOutcome> {
throw new Error("This payload store does not support deletion.");
}
}

/** A permanent payload storage or size-limit failure. */
export class PayloadStorageException extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "PayloadStorageException";
}
}

/** Opt-in payload externalization. Configure the same store on all clients and workers. */
export interface PayloadStorageOptions {
store: PayloadStore;
/** Inclusive serialized UTF-8 size threshold. Defaults to 256 KiB; maximum 1 MiB. */
thresholdBytes?: number;
/** Maximum serialized size of one externalized payload. Defaults to 10 MiB. */
maxPayloadBytes?: number;
}
32 changes: 32 additions & 0 deletions packages/durabletask-js/src/payloads/run-bounded.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

/** Stops dispatch on failure/cancellation, drains started I/O, then throws the first message-order failure. */
export async function runBounded(operations: (() => Promise<void>)[], signal?: AbortSignal): Promise<void> {
const active = new Set<Promise<void>>();
const failures = new Map<number, unknown>();
let cancellationPreventedDispatch = false;
for (let ordinal = 0; ordinal < operations.length; ordinal++) {
if (active.size >= 8) await Promise.race(active);
if (failures.size) break;
if (signal?.aborted) {
cancellationPreventedDispatch = true;
break;
}
const operation = operations[ordinal];
const tracked = (async () => {
try {
await operation();
} catch (error) {
failures.set(ordinal, error);
}
})();
active.add(tracked);
void tracked.then(() => active.delete(tracked));
// Observe synchronously rejected operations before claiming the next dispatch.
await Promise.resolve();
}
await Promise.all(active);
if (failures.size) throw failures.get(Math.min(...failures.keys()));
if (cancellationPreventedDispatch) signal?.throwIfAborted();
}
Loading
Loading