diff --git a/packages/durabletask-js-export-history/src/client/export-history-client.ts b/packages/durabletask-js-export-history/src/client/export-history-client.ts index 9e13e84..ae9c998 100644 --- a/packages/durabletask-js-export-history/src/client/export-history-client.ts +++ b/packages/durabletask-js-export-history/src/client/export-history-client.ts @@ -225,13 +225,17 @@ export class ExportHistoryJobClient { request, ); - // Then terminate the linked export orchestration if it exists + // Then terminate the linked export orchestration if it exists. + // Only swallow "not found" errors (orchestration doesn't exist or already purged). + // Re-throw all other errors (network failures, auth errors, timeouts, etc.). try { await this.client.terminateOrchestration(orchestrationInstanceId, "Export job deleted"); await this.client.waitForOrchestrationCompletion(orchestrationInstanceId, false, 30); await this.client.purgeOrchestration(orchestrationInstanceId); - } catch { - // Orchestration instance doesn't exist or already purged - this is expected + } catch (e: unknown) { + if (!isNotFoundError(e)) { + throw e; + } } } } @@ -268,3 +272,22 @@ function matchesFilter(state: ExportJobState, filter: ExportJobQuery): boolean { (state.createdAt !== undefined && new Date(state.createdAt) <= filter.createdTo); return statusMatches && createdFromMatches && createdToMatches; } + +/** gRPC status code for NOT_FOUND (well-known constant from the gRPC spec). */ +const GRPC_STATUS_NOT_FOUND = 5; + +/** + * Checks whether an error is a gRPC NOT_FOUND error. + * + * Uses duck typing to inspect the error's `code` property without requiring + * a direct dependency on `@grpc/grpc-js`. gRPC `ServiceError` objects always + * carry a numeric `code` field matching the standard gRPC status codes. + */ +function isNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code: unknown }).code === GRPC_STATUS_NOT_FOUND + ); +} diff --git a/packages/durabletask-js-export-history/test/export-history-client-delete.spec.ts b/packages/durabletask-js-export-history/test/export-history-client-delete.spec.ts new file mode 100644 index 0000000..7d4a031 --- /dev/null +++ b/packages/durabletask-js-export-history/test/export-history-client-delete.spec.ts @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { ExportHistoryJobClient } from "../src/client/export-history-client"; +import { ExportHistoryStorageOptions } from "../src/models"; +import { ORCHESTRATOR_INSTANCE_ID_PREFIX } from "../src/constants"; + +/** gRPC status code constants for test readability. */ +const GRPC_STATUS = { + NOT_FOUND: 5, + UNAVAILABLE: 14, + UNAUTHENTICATED: 16, + INTERNAL: 13, +}; + +/** + * Creates a mock gRPC error with the specified status code. + */ +function createGrpcError(code: number, message: string): Error & { code: number } { + const error = new Error(message) as Error & { code: number }; + error.code = code; + return error; +} + +/** + * Creates a minimal mock of TaskHubGrpcClient for testing the delete() method. + */ +function createMockClient(overrides?: { + scheduleNewOrchestration?: jest.Mock; + terminateOrchestration?: jest.Mock; + waitForOrchestrationCompletion?: jest.Mock; + purgeOrchestration?: jest.Mock; +}) { + return { + scheduleNewOrchestration: overrides?.scheduleNewOrchestration ?? jest.fn().mockResolvedValue("op-instance-123"), + terminateOrchestration: overrides?.terminateOrchestration ?? jest.fn().mockResolvedValue(undefined), + waitForOrchestrationCompletion: overrides?.waitForOrchestrationCompletion ?? jest.fn().mockResolvedValue(undefined), + purgeOrchestration: overrides?.purgeOrchestration ?? jest.fn().mockResolvedValue(undefined), + } as unknown as ConstructorParameters[0]; +} + +const TEST_STORAGE_OPTIONS: ExportHistoryStorageOptions = { + connectionString: "DefaultEndpointsProtocol=https;AccountName=test;AccountKey=key;EndpointSuffix=core.windows.net", + containerName: "test-container", +}; + +const TEST_JOB_ID = "test-job-1"; + +describe("ExportHistoryJobClient.delete()", () => { + it("should complete successfully when orchestration exists and all cleanup succeeds", async () => { + const terminateMock = jest.fn().mockResolvedValue(undefined); + const waitMock = jest.fn().mockResolvedValue(undefined); + const purgeMock = jest.fn().mockResolvedValue(undefined); + + const client = createMockClient({ + terminateOrchestration: terminateMock, + waitForOrchestrationCompletion: waitMock, + purgeOrchestration: purgeMock, + }); + + const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS); + await expect(jobClient.delete()).resolves.toBeUndefined(); + + const expectedOrchId = `${ORCHESTRATOR_INSTANCE_ID_PREFIX}${TEST_JOB_ID}`; + expect(terminateMock).toHaveBeenCalledWith(expectedOrchId, "Export job deleted"); + expect(waitMock).toHaveBeenCalledWith(expectedOrchId, false, 30); + expect(purgeMock).toHaveBeenCalledWith(expectedOrchId); + }); + + it("should swallow gRPC NOT_FOUND error from terminateOrchestration", async () => { + const terminateMock = jest.fn().mockRejectedValue( + createGrpcError(GRPC_STATUS.NOT_FOUND, "Orchestration not found"), + ); + + const client = createMockClient({ terminateOrchestration: terminateMock }); + const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS); + + await expect(jobClient.delete()).resolves.toBeUndefined(); + }); + + it("should swallow gRPC NOT_FOUND error from purgeOrchestration", async () => { + const purgeMock = jest.fn().mockRejectedValue( + createGrpcError(GRPC_STATUS.NOT_FOUND, "Instance not found"), + ); + + const client = createMockClient({ purgeOrchestration: purgeMock }); + const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS); + + await expect(jobClient.delete()).resolves.toBeUndefined(); + }); + + it("should re-throw gRPC UNAVAILABLE error (network failure)", async () => { + const terminateMock = jest.fn().mockRejectedValue( + createGrpcError(GRPC_STATUS.UNAVAILABLE, "Connection refused"), + ); + + const client = createMockClient({ terminateOrchestration: terminateMock }); + const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS); + + await expect(jobClient.delete()).rejects.toThrow("Connection refused"); + }); + + it("should re-throw gRPC UNAUTHENTICATED error (auth failure)", async () => { + const terminateMock = jest.fn().mockRejectedValue( + createGrpcError(GRPC_STATUS.UNAUTHENTICATED, "Token expired"), + ); + + const client = createMockClient({ terminateOrchestration: terminateMock }); + const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS); + + await expect(jobClient.delete()).rejects.toThrow("Token expired"); + }); + + it("should re-throw gRPC INTERNAL error (server failure)", async () => { + const purgeMock = jest.fn().mockRejectedValue( + createGrpcError(GRPC_STATUS.INTERNAL, "Internal server error"), + ); + + const client = createMockClient({ purgeOrchestration: purgeMock }); + const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS); + + await expect(jobClient.delete()).rejects.toThrow("Internal server error"); + }); + + it("should re-throw timeout errors from waitForOrchestrationCompletion", async () => { + const waitMock = jest.fn().mockRejectedValue(new Error("Timed out waiting for orchestration")); + + const client = createMockClient({ waitForOrchestrationCompletion: waitMock }); + const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS); + + await expect(jobClient.delete()).rejects.toThrow("Timed out waiting for orchestration"); + }); + + it("should re-throw plain errors without a gRPC code property", async () => { + const terminateMock = jest.fn().mockRejectedValue(new Error("Unexpected error")); + + const client = createMockClient({ terminateOrchestration: terminateMock }); + const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS); + + await expect(jobClient.delete()).rejects.toThrow("Unexpected error"); + }); + + it("should still schedule entity deletion before attempting orchestration cleanup", async () => { + const scheduleMock = jest.fn().mockResolvedValue("op-instance-123"); + const terminateMock = jest.fn().mockRejectedValue( + createGrpcError(GRPC_STATUS.NOT_FOUND, "Orchestration not found"), + ); + + const client = createMockClient({ + scheduleNewOrchestration: scheduleMock, + terminateOrchestration: terminateMock, + }); + + const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS); + await jobClient.delete(); + + expect(scheduleMock).toHaveBeenCalledTimes(1); + }); +});