Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
bdeb0c6
feat: add bounded payload storage contracts and processing
YunchuWang Oct 16, 2026
b6c27b1
feat: integrate payload transport with SDK clients and workers
YunchuWang Oct 16, 2026
807367b
feat: add interoperable Azure Blob payload provider
YunchuWang Oct 16, 2026
7ce147d
feat: add payload purge protocol and management client
YunchuWang Oct 16, 2026
8040795
feat: add reserved payload cleanup worker tasks
YunchuWang Oct 16, 2026
d5c9df7
fix: externalize continue-as-new carryover payloads
YunchuWang Oct 19, 2026
55dfcf3
fix: preflight payload sizes before uploads
YunchuWang Oct 19, 2026
1e7f3d8
Merge repaired payload core into SDK phase
YunchuWang Oct 19, 2026
bee61f1
Merge repaired SDK phase into Blob provider phase
YunchuWang Oct 19, 2026
847d0f8
docs: clarify payload preflight and orphan limitations
YunchuWang Oct 19, 2026
5b7baf4
Merge repaired Blob phase into purge client phase
YunchuWang Oct 19, 2026
e9940fb
Merge repaired purge client phase into cleanup worker phase
YunchuWang Oct 19, 2026
3fab3aa
fix: abandon queued work on payload processing failure
YunchuWang Oct 20, 2026
dcb09f4
fix: isolate completion wait RPC deadline retries
YunchuWang Oct 20, 2026
1f82ee5
Merge reviewed SDK release and wait repairs into Blob phase
YunchuWang Oct 20, 2026
bccd9ca
Merge repaired SDK ancestry into purge client phase
YunchuWang Oct 20, 2026
aa425b7
Merge approved SDK failure handling into cleanup worker phase
YunchuWang Oct 20, 2026
aeb598f
fix: skip recognized payload tokens during externalization
YunchuWang Oct 20, 2026
bee1313
fix: preserve cancellation during payload externalization
YunchuWang Oct 20, 2026
b33c264
Merge reviewed token and cancellation guards into payload phase 2
YunchuWang Oct 20, 2026
932306c
Merge reviewed token and cancellation guards into payload phase 3
YunchuWang Oct 20, 2026
14edd91
Merge reviewed token and cancellation guards into payload phase 4
YunchuWang Oct 20, 2026
c2080ef
Merge reviewed token and cancellation guards into payload phase 5
YunchuWang Oct 20, 2026
137bb54
fix: process rewind history input payloads
YunchuWang Oct 22, 2026
bf9a7ef
fix(client): isolate scheduling RPC error translation
YunchuWang Oct 22, 2026
b4ddad9
Merge reviewed rewind mapping into payload phase 2
YunchuWang Oct 22, 2026
535bfcb
Merge reviewed rewind and scheduling fixes into payload phase 3
YunchuWang Oct 22, 2026
08de2e0
Merge reviewed rewind and scheduling fixes into payload phase 4
YunchuWang Oct 22, 2026
6bd14ca
Merge reviewed rewind and scheduling fixes into payload phase 5
YunchuWang Oct 22, 2026
f3ea69f
fix(client): centralize unary RPC error boundaries
YunchuWang Oct 22, 2026
71633cd
Merge reviewed shared unary pipeline into payload phase 3
YunchuWang Oct 22, 2026
7715b7a
Merge reviewed shared unary pipeline into payload phase 4
YunchuWang Oct 22, 2026
70a3b03
Merge reviewed shared unary pipeline into payload phase 5
YunchuWang Oct 22, 2026
7c9d67f
fix(client): isolate auto-purge runner deduplication
YunchuWang Oct 22, 2026
20ee1eb
Merge reviewed runner RPC boundary into payload phase 5
YunchuWang Oct 22, 2026
437a92f
fix(azureblob): enforce network deadlines and aligned HTTP retries
YunchuWang Oct 27, 2026
e7f1535
Merge main ancestry after payload prerequisite squash merges
YunchuWang Oct 27, 2026
518adab
Merge reviewed Blob resilience into payload phase 4
YunchuWang Oct 27, 2026
af191ce
Merge reviewed Blob resilience into payload phase 5
YunchuWang Oct 27, 2026
5c150d4
fix(azureblob): preserve secondary retry integrity and isolate HTTP f…
YunchuWang Oct 27, 2026
65793ae
Merge reviewed Blob secondary integrity into payload phase 4
YunchuWang Oct 27, 2026
804296c
Merge reviewed Blob secondary integrity into payload phase 5
YunchuWang Oct 27, 2026
2f90339
fix(azureblob): cap fixed retry jitter at configured delay
YunchuWang Oct 27, 2026
6d0f688
Merge reviewed fixed retry cap into payload phase 4
YunchuWang Oct 27, 2026
f88101f
Merge reviewed fixed retry cap into payload phase 5
YunchuWang Oct 27, 2026
9ad6550
fix(azureblob): eagerly release response I/O and validate local test …
YunchuWang Oct 27, 2026
86288fe
test(azureblob): accept padded endpoints and await response closure
YunchuWang Oct 27, 2026
bc249ab
Merge reviewed provider teardown fixes into payload phase 4
YunchuWang Oct 27, 2026
140fe48
Merge reviewed provider teardown fixes into payload phase 5
YunchuWang Oct 27, 2026
fcdc5a7
refactor(azureblob): delegate HTTP retries to Azure SDK
YunchuWang Oct 28, 2026
cdc440b
Merge approved native SDK retries into payload phase 4
YunchuWang Oct 28, 2026
1f5f52e
Merge approved native SDK retries into payload phase 5
YunchuWang Oct 28, 2026
d6190ee
fix: cancel pending gRPC metadata waits before dispatch
YunchuWang Oct 28, 2026
27e6072
Merge reviewed metadata cancellation into payload phase 5
YunchuWang Oct 28, 2026
ce561b6
refactor(azureblob): use native SDK transport and cancellation
YunchuWang Oct 29, 2026
40aee55
Merge SDK-only payload transport into phase 4
YunchuWang Oct 29, 2026
2548ab9
Merge SDK-only payload transport into phase 5
YunchuWang Oct 29, 2026
e5844ee
test(azureblob): resolve child fixture workspace source imports
YunchuWang Oct 29, 2026
b856238
Merge clean-checkout payload fixture fix into phase 4
YunchuWang Oct 29, 2026
7e7bd8d
Merge clean-checkout payload fixture fix into phase 5
YunchuWang Oct 29, 2026
ebe2d8e
Merge merged provider main ancestry into payload phase 4
YunchuWang Oct 30, 2026
e445b3e
Merge payload phase 4 ancestry sync into phase 5
YunchuWang Oct 30, 2026
24af4a1
fix: preserve cleanup failure and cancellation boundaries
YunchuWang Nov 4, 2026
8079635
fix: scope payload purge exemptions to task kind
YunchuWang Nov 4, 2026
185d061
Merge current main access-scope hardening into payload cleanup worker
YunchuWang Nov 5, 2026
4179037
Fix malformed payload token URI decoding
YunchuWang Nov 5, 2026
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

### New

- Add reserved cleanup worker tasks with bounded deletion chunks, retry/quarantine handling,
configuration-preserving continue-as-new, authenticated current-channel RPCs and filter/version isolation.
This completes the cleanup runtime; aggregate release/consumer validation follows in phase 6.
- Add the DTS payload-purge protocol and `setLargePayloadAutoPurge()` client lifecycle,
including setting-first enable/disable and reserved-instance identity verification.
A compatible .NET worker or the later cleanup-worker phase is required; do not publish partial phases.
Expand Down Expand Up @@ -50,6 +53,15 @@

### Fixes

- Scope reserved payload-cleanup version exemptions and filters to their task kind, preserving
customer tasks with reserved names in another category and their configured versions.
- Validate malformed v2 cleanup tokens before store I/O; use `InvalidPayloadTokenError` for
provider-specific token failures, including path-style missing blob names. Retry uncertain
store failures such as native fetch `TypeError` network errors, not every `TypeError` as malformed.
- Stop queued cleanup deletes on caller/worker cancellation, drain started deletes and preserve
the original cancellation reason. The independent 60-second per-delete timeout remains retryable.
- Preserve cleanup activity metadata failures unchanged; only scheduler RPC failures trigger
precondition idling or unsupported-backend handling.
- Cancel metadata acquisition waits before dispatch and preserve the original cancellation reason,
including auto-purge setting and runner lifecycle calls. Cancellation detaches the SDK wait;
third-party metadata generation may finish later, but cannot dispatch a canceled RPC.
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ propagate unchanged. Completion waits retry scheduler deadlines with fresh metad
re-uploading the request or retrying response hydration. Cancellation stops RPCs and retry backoff.

Azure-managed builders expose `.useExternalizedPayloads({ store })` and require core `>=0.4.1`
for the public payload types. This phase works with a custom/in-memory store and does not import
an Azure Blob provider or register cleanup tasks. Complete the six-phase feature stack before release.
for the public payload types. A custom/in-memory store or the optional Azure Blob provider can
implement storage independently of the transport. Complete the six-phase feature stack before release.

### Payload cleanup client protocol

Expand All @@ -56,8 +56,11 @@ the reserved Running orchestration, then signals its batch size. Disabling only
backend setting. These steps are not transactional; earlier successful steps are not rolled back.

This client operation does not require blob credentials. It requires a backend implementing
the DTS `LargePayloadPurge` service and a compatible worker (the existing .NET implementation
or the later phase 5 JavaScript runner). This phase does not register worker cleanup tasks.
the DTS `LargePayloadPurge` service and a compatible worker. Payload-enabled JavaScript workers
now register the reserved cleanup orchestrator and activities automatically, preserve their
wildcard filters and bypass business version policy without changing customer filters.
Deletion verifies ownership and ETags, batches 50 tokens per activity and limits concurrency;
the runner backs off empty/retry-only batches and preserves configuration through continue-as-new.
Do not publish the feature until all six phases are complete.

Single-instance purges now identify the instance kind even on management-only clients without
Expand Down
3 changes: 3 additions & 0 deletions packages/durabletask-js-azureblob/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

### Fixes

- Classify malformed percent/UTF-8 encodings in v2 token paths as `InvalidPayloadTokenError`
so cleanup quarantines them without storage I/O. Keep access-scope denials and unrelated
storage failures retryable.
- Default payload reads and deletion to the configured storage endpoint and container, including
normalized blob paths and path-style account names. Out-of-location deletion references
remain available for retry. Changing storage locations requires explicit migration or a
Expand Down
35 changes: 26 additions & 9 deletions packages/durabletask-js-azureblob/src/blob-payload-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import {
RestError,
StoragePipelineOptions,
} from "@azure/storage-blob";
import { PayloadStore, PayloadStorageException, PayloadDeleteOutcome } from "@microsoft/durabletask-js";
import {
InvalidPayloadTokenError,
PayloadStore,
PayloadStorageException,
PayloadDeleteOutcome,
} from "@microsoft/durabletask-js";
import { randomUUID } from "crypto";
import { Writable } from "stream";
import { pipeline } from "stream/promises";
Expand Down Expand Up @@ -255,7 +260,7 @@ export class BlobPayloadStore extends PayloadStore {
const rest = token.slice("blob:v1:".length);
const separator = rest.indexOf(":");
if (separator < 1 || separator === rest.length - 1) {
throw new TypeError("Invalid external payload token.");
throw new InvalidPayloadTokenError("Invalid external payload token.");
}
const containerName = rest.slice(0, separator);
const primary = this._primaryLocation;
Expand All @@ -266,7 +271,7 @@ export class BlobPayloadStore extends PayloadStore {
this._validateBlobUrl(new URL(blob.url), primary, containerName);
return blob;
}
if (!token.startsWith("blob:v2:")) throw new TypeError("Invalid external payload token.");
if (!token.startsWith("blob:v2:")) throw new InvalidPayloadTokenError("Invalid external payload token.");
const value = token.slice("blob:v2:".length).split(/[?#]/, 1)[0];
if (/\/(?:\.|%2e){1,2}(?=\/|$)/i.test(value)) {
throw new PayloadStorageException("The payload blob URL must not contain dot-segment traversal.");
Expand All @@ -275,21 +280,22 @@ export class BlobPayloadStore extends PayloadStore {
try {
url = new URL(token.slice("blob:v2:".length));
} catch (error) {
throw new TypeError("Invalid external payload blob URL.", { cause: error });
throw new InvalidPayloadTokenError("Invalid external payload blob URL.", { cause: error });
}
if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password) {
throw new TypeError("Invalid external payload blob URL.");
throw new InvalidPayloadTokenError("Invalid external payload blob URL.");
}
url.search = "";
url.hash = "";
const location = this._locations.find((candidate) => this._relativeBlobPath(url, candidate) !== undefined);
if (!location) throw new PayloadStorageException("The payload blob URL is outside the configured access scope.");
const relative = this._relativeBlobPath(url, location)!;
const separator = relative.indexOf("/");
if (separator < 1 || separator === relative.length - 1) throw new TypeError("Invalid external payload blob URL.");
if (separator < 1 || separator === relative.length - 1)
throw new InvalidPayloadTokenError("Invalid external payload blob URL.");
this._validateBlobUrl(url, location);
const containerName = decodeURIComponent(relative.slice(0, separator));
const name = decodeURIComponent(relative.slice(separator + 1));
const containerName = decodeTokenComponent(relative.slice(0, separator));
const name = decodeTokenComponent(relative.slice(separator + 1));
if (name.split("/").some((segment) => segment === "." || segment === "..")) {
throw new PayloadStorageException("The payload blob name must not contain dot segments.");
}
Expand All @@ -312,7 +318,7 @@ export class BlobPayloadStore extends PayloadStore {
const separator = relative?.indexOf("/") ?? -1;
const container =
relative !== undefined && separator > 0
? encodeURIComponent(decodeURIComponent(relative.slice(0, separator)))
? encodeURIComponent(decodeTokenComponent(relative.slice(0, separator)))
: undefined;
if (
relative === undefined ||
Expand Down Expand Up @@ -355,6 +361,17 @@ export class BlobPayloadStore extends PayloadStore {
}
}

function decodeTokenComponent(value: string): string {
try {
return decodeURIComponent(value);
} catch (error) {
if (error instanceof URIError) {
throw new InvalidPayloadTokenError("Invalid external payload blob URL encoding.", { cause: error });
}
throw error;
}
}

function trimTrailingSlashes(value: string): string {
let end = value.length;
while (end > 0 && value.charCodeAt(end - 1) === 47) end--;
Expand Down
111 changes: 110 additions & 1 deletion packages/durabletask-js-azureblob/test/blob-payload-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ import {
StorageRetryOptions,
StorageRetryPolicyType,
} from "@azure/storage-blob";
import { PayloadDeleteOutcome, PayloadStorageException } from "@microsoft/durabletask-js";
import {
InvalidPayloadTokenError,
NoOpLogger,
PayloadDeleteOutcome,
PayloadStorageException,
} from "@microsoft/durabletask-js";
import { deletePayloadChunk } from "../../durabletask-js/src/payloads/purge-worker";
import type { StringValue } from "google-protobuf/google/protobuf/wrappers_pb";
import { PayloadProcessor } from "../../durabletask-js/src/payloads/payload-processor";
import * as pb from "../../durabletask-js/src/proto/orchestrator_service_pb";
Expand Down Expand Up @@ -88,6 +94,109 @@ describe("BlobPayloadStore", () => {
http = new RecordingClient();
});

it.each(["127.0.0.1", "localhost"])(
"quarantines the real provider's missing path-style blob name without storage I/O (%s)",
async (host) => {
const endpoint = `http://${host}:10000/devstoreaccount1`;
const store = new BlobPayloadStore({
connectionString: `DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=${accountKey};BlobEndpoint=${endpoint}`,
clientOptions: { httpClient: http },
});
const token = `blob:v2:${endpoint}/payloads/`;
await expect(store.delete(token)).rejects.toBeInstanceOf(InvalidPayloadTokenError);
await expect(deletePayloadChunk(store, [token], new NoOpLogger())).resolves.toEqual([{ Disposition: 3 }]);
expect(http.requests).toHaveLength(0);
},
);

it.each(["blob:v2:https://account.invalid/archive/blob", "blob:v2:https://other.invalid/archive/blob"])(
"keeps valid out-of-scope cleanup references retryable until explicitly authorized (%#)",
async (token) => {
const clientOptions = { httpClient: http, retryOptions: { maxTries: 1 } };
const restricted = new BlobPayloadStore({ connectionString, clientOptions });
const failure: unknown = await restricted.delete(token).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(PayloadStorageException);
expect(failure).not.toBeInstanceOf(InvalidPayloadTokenError);
await expect(deletePayloadChunk(restricted, [token], new NoOpLogger())).resolves.toEqual([{ Disposition: 2 }]);
expect(http.requests).toHaveLength(0);

const authorized = token.includes("other.invalid")
? new BlobPayloadStore({
accountUri: "https://account.invalid",
credential: {
getToken: async () => ({ token: "synthetic-token", expiresOnTimestamp: Date.now() + 3600000 }),
},
additionalAccountUris: ["https://other.invalid"],
clientOptions,
})
: new BlobPayloadStore({ connectionString, allowAllContainersInConfiguredAccount: true, clientOptions });
await expect(deletePayloadChunk(authorized, [token], new NoOpLogger())).resolves.toEqual([{ Disposition: 1 }]);
expect(http.requests.map((request) => request.method)).toEqual(["HEAD", "DELETE"]);
expect(http.requests[1].headers.get("if-match")).toBe('"owned-etag"');
},
);

describe.each([
{ endpoint: "https://account.invalid", connectionString },
{
endpoint: "http://127.0.0.1:10000/devstoreaccount1",
connectionString: `DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=${accountKey};BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1`,
},
])("token URI decoding ($endpoint)", ({ endpoint, connectionString }) => {
const malformedPaths = [
"durabletask-payloads/%FF",
"durabletask-payloads/%",
"durabletask-payloads/%E2%82",
"%FF/blob",
"%/blob",
"%E2%82/blob",
];
const make = () =>
new BlobPayloadStore({
connectionString,
clientOptions: { httpClient: http, retryOptions: { maxTries: 1 } },
});

it.each(malformedPaths)("reports malformed encoding as a typed token failure (%s)", async (path) => {
const failure: unknown = await make()
.delete(`blob:v2:${endpoint}/${path}`)
.catch((error: unknown) => error);
expect(http.requests).toHaveLength(0);
expect(failure).toBeInstanceOf(InvalidPayloadTokenError);
expect(failure).toHaveProperty("cause", expect.any(URIError));
});

it.each(malformedPaths)("quarantines malformed encoding without storage I/O (%s)", async (path) => {
const result = await deletePayloadChunk(make(), [`blob:v2:${endpoint}/${path}`], new NoOpLogger());
expect(http.requests).toHaveLength(0);
expect(result).toEqual([{ Disposition: 3 }]);
});

it.each([
["/durabletask%2Dpayloads/folder%2Fnested%2F%25FF%2F100%25", "/durabletask-payloads/folder/nested/%25FF/100%25"],
["/durabletask-payloads/folder/a%252Fb%20%25", "/durabletask-payloads/folder/a%252Fb%20%25"],
])("preserves valid percent and nested names through conditional deletion (%s)", async (path, expected) => {
const store = make();
const token = `blob:v2:${endpoint}${path}`;
await expect(store.download(token)).resolves.toBe("payload");
await expect(deletePayloadChunk(store, [token], new NoOpLogger())).resolves.toEqual([{ Disposition: 1 }]);
expect(http.requests.map((request) => request.method)).toEqual(["GET", "HEAD", "DELETE"]);
for (const request of http.requests) {
const url = new URL(request.url);
expect(url.origin + url.pathname).toBe(`${endpoint}${expected}`);
}
expect(http.requests[2].headers.get("if-match")).toBe('"owned-etag"');
});

it("keeps a transport URIError retryable rather than quarantining it", async () => {
http.failures.push(new URIError("Synthetic transport failure"));
await expect(
deletePayloadChunk(make(), [`blob:v2:${endpoint}/durabletask-payloads/blob`], new NoOpLogger()),
).resolves.toEqual([{ Disposition: 2 }]);
expect(http.requests.map((request) => request.method)).toEqual(["HEAD"]);
});
});

describe("BlobPayloadStore native SDK retries and options", () => {
let http: RecordingClient;
const token = "blob:v2:https://account.invalid/durabletask-payloads/blob";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe("Azure-managed payload configuration", () => {
expect(client["_payloads"]).toBeDefined();
await client.stop();
});
it("preserves worker payload settings and business registrations", () => {
it("preserves worker payload settings, business registrations, and internal wildcard filters", () => {
const worker = new DurableTaskAzureManagedWorkerBuilder()
.useExternalizedPayloads({ store: new Store() })
.addNamedOrchestrator("business", () => undefined)
Expand All @@ -41,6 +41,6 @@ describe("Azure-managed payload configuration", () => {
.getWorkitemfilters()!
.getOrchestrationsList()
.map((f) => f.getName()),
).toEqual(["business"]);
).toEqual(["business", "BlobPurgeJobOrchestrator"]);
});
});
1 change: 1 addition & 0 deletions packages/durabletask-js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// Client and Worker
export {
PayloadStore,
InvalidPayloadTokenError,
PayloadStorageOptions,
PayloadStorageException,
PayloadDeleteOutcome,
Expand Down
13 changes: 12 additions & 1 deletion packages/durabletask-js/src/payloads/payload-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,23 @@ export abstract class PayloadStore {
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. */
/**
* Optional cleanup support. Never delete a blob without verifying ownership and its ETag.
* Use InvalidPayloadTokenError only for deterministic token-validation failures.
*/
async delete(_token: string, _signal?: AbortSignal): Promise<PayloadDeleteOutcome> {
throw new Error("This payload store does not support deletion.");
}
}

/** A deterministic malformed storage token, distinct from uncertain I/O TypeErrors. */
export class InvalidPayloadTokenError extends TypeError {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "InvalidPayloadTokenError";
}
}

/** A permanent payload storage or size-limit failure. */
export class PayloadStorageException extends Error {
constructor(message: string, options?: { cause?: unknown }) {
Expand Down
Loading
Loading