Repository navigation
feat: [1/6] add core payload contracts and bounded processing #388
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
bdeb0c6
feat: add bounded payload storage contracts and processing
YunchuWang d5c9df7
fix: externalize continue-as-new carryover payloads
YunchuWang 55dfcf3
fix: preflight payload sizes before uploads
YunchuWang aeb598f
fix: skip recognized payload tokens during externalization
YunchuWang bee1313
fix: preserve cancellation during payload externalization
YunchuWang 137bb54
fix: process rewind history input payloads
YunchuWang File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
207 changes: 207 additions & 0 deletions
207
packages/durabletask-js/src/payloads/payload-processor.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,207 @@ | ||
| // 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 based on 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.getCompleteorchestration()?.getCarryovereventsList() ?? []) { | ||
| this._historyFields(event, fields); | ||
| } | ||
| 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 candidates: { field: StringValue; value: string; size: number }[] = []; | ||
| for (const field of fields) { | ||
| const value = field?.getValue(); | ||
| if (!field || !value || this._options.store.isKnownPayloadToken(value)) continue; | ||
| const size = Buffer.byteLength(value, "utf8"); | ||
| if (size < this._threshold) continue; | ||
| candidates.push({ field, value, size }); | ||
|
YunchuWang marked this conversation as resolved.
Resolved
|
||
| } | ||
| try { | ||
| if (candidates.length) signal?.throwIfAborted(); | ||
| for (const { size } of candidates) { | ||
| if (size > this._maximum) { | ||
| throw new PayloadStorageException( | ||
| `Payload size ${size} bytes exceeds the configured maximum of ${this._maximum} bytes.`, | ||
| ); | ||
| } | ||
| } | ||
| await runBounded( | ||
| candidates.map(({ field, value }) => async () => { | ||
| field.setValue(await this._options.store.upload(value, signal)); | ||
| }), | ||
| signal, | ||
| ); | ||
| } catch (error) { | ||
| if ( | ||
| signal?.aborted || | ||
| !isPermanentStorageFailure(error) || | ||
|
YunchuWang marked this conversation as resolved.
Resolved
|
||
| !(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(), | ||
|
YunchuWang marked this conversation as resolved.
Resolved
|
||
| event.getExecutionrewound()?.getInput(), | ||
| 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; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.