Enhance distributed transaction handling and order fulfillment orchestration

- Introduced retry logic in DistributedTransactionService to improve resilience during step execution, allowing for configurable maximum retries.
- Refactored order fulfillment orchestration to include step status updates, enhancing visibility into the execution process and error handling.
- Implemented createTrackedStep utility to streamline step execution and status management, ensuring consistent handling of success and failure states.
- Improved toast message handling in usePaymentRefresh and SimFeatureToggles components for better user feedback during asynchronous operations.
- Added cleanup logic in SimManagementSection to prevent memory leaks and ensure proper aborting of ongoing requests.
This commit is contained in:
barsa 2025-11-17 11:04:53 +09:00
parent 01d5127351
commit b5533994c2
5 changed files with 163 additions and 23 deletions

View File

@ -103,9 +103,10 @@ export class DistributedTransactionService {
const { const {
description, description,
timeout = 120000, // 2 minutes default for distributed operations timeout = 120000, // 2 minutes default for distributed operations
maxRetries: _maxRetries = 1, // Less retries for distributed operations maxRetries: configuredMaxRetries = 1,
continueOnNonCriticalFailure = false, continueOnNonCriticalFailure = false,
} = options; } = options;
const maxRetries = Math.max(0, Math.floor(configuredMaxRetries ?? 0));
const transactionId = this.generateTransactionId(); const transactionId = this.generateTransactionId();
const startTime = Date.now(); const startTime = Date.now();
@ -130,7 +131,7 @@ export class DistributedTransactionService {
try { try {
const stepStartTime = Date.now(); const stepStartTime = Date.now();
const result = await this.executeStepWithTimeout(step, timeout); const result = await this.executeStepWithRetry(step, timeout, maxRetries, transactionId);
const stepDuration = Date.now() - stepStartTime; const stepDuration = Date.now() - stepStartTime;
const key = step.id as keyof StepResultMap<TSteps>; const key = step.id as keyof StepResultMap<TSteps>;
@ -402,6 +403,36 @@ export class DistributedTransactionService {
return rollbacksExecuted; return rollbacksExecuted;
} }
private async executeStepWithRetry<TResult>(
step: DistributedStep<string, TResult>,
timeout: number,
maxRetries: number,
transactionId: string
): Promise<TResult> {
const totalAttempts = step.retryable ? Math.max(1, maxRetries + 1) : 1;
let attempt = 0;
// attempt counter represents completed attempts; loop until success or attempts exhausted
while (attempt < totalAttempts) {
try {
return await this.executeStepWithTimeout(step, timeout);
} catch (error) {
attempt++;
if (attempt >= totalAttempts) {
throw error;
}
this.logger.warn(`Retrying step: ${step.id} [${transactionId}]`, {
attempt: attempt + 1,
maxAttempts: totalAttempts,
error: getErrorMessage(error),
});
}
}
throw new Error(`Step ${step.id} failed after ${totalAttempts} attempts`);
}
private generateTransactionId(): string { private generateTransactionId(): string {
return `dtx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; return `dtx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
} }

View File

@ -98,11 +98,13 @@ export class OrderFulfillmentOrchestrator {
try { try {
// Step 1: Validation (no rollback needed) // Step 1: Validation (no rollback needed)
this.updateStepStatus(context, "validation", "in_progress");
try { try {
context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest( context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest(
sfOrderId, sfOrderId,
idempotencyKey idempotencyKey
); );
this.updateStepStatus(context, "validation", "completed");
if (context.validation.isAlreadyProvisioned) { if (context.validation.isAlreadyProvisioned) {
this.logger.log("Order already provisioned, skipping fulfillment", { sfOrderId }); this.logger.log("Order already provisioned, skipping fulfillment", { sfOrderId });
@ -122,6 +124,7 @@ export class OrderFulfillmentOrchestrator {
return context; return context;
} }
} catch (error) { } catch (error) {
this.updateStepStatus(context, "validation", "failed", getErrorMessage(error));
this.logger.error("Fulfillment validation failed", { this.logger.error("Fulfillment validation failed", {
sfOrderId, sfOrderId,
error: getErrorMessage(error), error: getErrorMessage(error),
@ -157,7 +160,7 @@ export class OrderFulfillmentOrchestrator {
{ {
id: "sf_status_update", id: "sf_status_update",
description: "Update Salesforce order status to Activating", description: "Update Salesforce order status to Activating",
execute: async () => { execute: this.createTrackedStep(context, "sf_status_update", async () => {
const result = await this.salesforceService.updateOrder({ const result = await this.salesforceService.updateOrder({
Id: sfOrderId, Id: sfOrderId,
Activation_Status__c: "Activating", Activation_Status__c: "Activating",
@ -171,7 +174,7 @@ export class OrderFulfillmentOrchestrator {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
return result; return result;
}, }),
rollback: async () => { rollback: async () => {
await this.salesforceService.updateOrder({ await this.salesforceService.updateOrder({
Id: sfOrderId, Id: sfOrderId,
@ -183,13 +186,15 @@ export class OrderFulfillmentOrchestrator {
{ {
id: "order_details", id: "order_details",
description: "Retain order details in context", description: "Retain order details in context",
execute: () => Promise.resolve(context.orderDetails), execute: this.createTrackedStep(context, "order_details", () =>
Promise.resolve(context.orderDetails)
),
critical: false, critical: false,
}, },
{ {
id: "mapping", id: "mapping",
description: "Map OrderItems to WHMCS format", description: "Map OrderItems to WHMCS format",
execute: () => { execute: this.createTrackedStep(context, "mapping", () => {
if (!context.orderDetails) { if (!context.orderDetails) {
return Promise.reject(new Error("Order details are required for mapping")); return Promise.reject(new Error("Order details are required for mapping"));
} }
@ -204,13 +209,13 @@ export class OrderFulfillmentOrchestrator {
}); });
return Promise.resolve(result); return Promise.resolve(result);
}, }),
critical: true, critical: true,
}, },
{ {
id: "whmcs_create", id: "whmcs_create",
description: "Create order in WHMCS", description: "Create order in WHMCS",
execute: async () => { execute: this.createTrackedStep(context, "whmcs_create", async () => {
if (!context.validation) { if (!context.validation) {
throw new OrderValidationException("Validation context is missing", { throw new OrderValidationException("Validation context is missing", {
sfOrderId, sfOrderId,
@ -242,7 +247,7 @@ export class OrderFulfillmentOrchestrator {
whmcsCreateResult = result; whmcsCreateResult = result;
return result; return result;
}, }),
rollback: () => { rollback: () => {
if (whmcsCreateResult?.orderId) { if (whmcsCreateResult?.orderId) {
// Note: WHMCS doesn't have an automated cancel API // Note: WHMCS doesn't have an automated cancel API
@ -263,7 +268,7 @@ export class OrderFulfillmentOrchestrator {
{ {
id: "whmcs_accept", id: "whmcs_accept",
description: "Accept/provision order in WHMCS", description: "Accept/provision order in WHMCS",
execute: async () => { execute: this.createTrackedStep(context, "whmcs_accept", async () => {
if (!whmcsCreateResult?.orderId) { if (!whmcsCreateResult?.orderId) {
throw new WhmcsOperationException("WHMCS order ID missing before acceptance step", { throw new WhmcsOperationException("WHMCS order ID missing before acceptance step", {
sfOrderId, sfOrderId,
@ -273,7 +278,7 @@ export class OrderFulfillmentOrchestrator {
await this.whmcsOrderService.acceptOrder(whmcsCreateResult.orderId, sfOrderId); await this.whmcsOrderService.acceptOrder(whmcsCreateResult.orderId, sfOrderId);
return { orderId: whmcsCreateResult.orderId }; return { orderId: whmcsCreateResult.orderId };
}, }),
rollback: () => { rollback: () => {
if (whmcsCreateResult?.orderId) { if (whmcsCreateResult?.orderId) {
// Note: WHMCS doesn't have an automated cancel API for accepted orders // Note: WHMCS doesn't have an automated cancel API for accepted orders
@ -295,7 +300,7 @@ export class OrderFulfillmentOrchestrator {
{ {
id: "sim_fulfillment", id: "sim_fulfillment",
description: "SIM-specific fulfillment (if applicable)", description: "SIM-specific fulfillment (if applicable)",
execute: async () => { execute: this.createTrackedStep(context, "sim_fulfillment", async () => {
if (context.orderDetails?.orderType === "SIM") { if (context.orderDetails?.orderType === "SIM") {
const configurations = this.extractConfigurations(payload.configurations); const configurations = this.extractConfigurations(payload.configurations);
await this.simFulfillmentService.fulfillSimOrder({ await this.simFulfillmentService.fulfillSimOrder({
@ -305,13 +310,13 @@ export class OrderFulfillmentOrchestrator {
return { completed: true as const }; return { completed: true as const };
} }
return { skipped: true as const }; return { skipped: true as const };
}, }),
critical: false, // SIM fulfillment failure shouldn't rollback the entire order critical: false, // SIM fulfillment failure shouldn't rollback the entire order
}, },
{ {
id: "sf_success_update", id: "sf_success_update",
description: "Update Salesforce with success", description: "Update Salesforce with success",
execute: async () => { execute: this.createTrackedStep(context, "sf_success_update", async () => {
const result = await this.salesforceService.updateOrder({ const result = await this.salesforceService.updateOrder({
Id: sfOrderId, Id: sfOrderId,
Status: "Completed", Status: "Completed",
@ -331,7 +336,7 @@ export class OrderFulfillmentOrchestrator {
}, },
}); });
return result; return result;
}, }),
rollback: async () => { rollback: async () => {
await this.salesforceService.updateOrder({ await this.salesforceService.updateOrder({
Id: sfOrderId, Id: sfOrderId,
@ -529,4 +534,44 @@ export class OrderFulfillmentOrchestrator {
steps: context.steps, steps: context.steps,
}; };
} }
private updateStepStatus(
context: OrderFulfillmentContext,
stepName: string,
status: OrderFulfillmentStep["status"],
error?: string
): void {
const step = context.steps.find(s => s.step === stepName);
if (!step) return;
const timestamp = new Date();
if (status === "in_progress") {
step.status = "in_progress";
step.startedAt = timestamp;
step.error = undefined;
return;
}
step.status = status;
step.completedAt = timestamp;
step.error = status === "failed" ? error : undefined;
}
private createTrackedStep<TResult>(
context: OrderFulfillmentContext,
stepName: string,
executor: () => Promise<TResult>
): () => Promise<TResult> {
return async () => {
this.updateStepStatus(context, stepName, "in_progress");
try {
const result = await executor();
this.updateStepStatus(context, stepName, "completed");
return result;
} catch (error) {
this.updateStepStatus(context, stepName, "failed", getErrorMessage(error));
throw error;
}
};
}
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import { paymentMethodListSchema, type PaymentMethodList } from "@customer-portal/domain/payments"; import { paymentMethodListSchema, type PaymentMethodList } from "@customer-portal/domain/payments";
import { useAuthSession } from "@/features/auth/services/auth.store"; import { useAuthSession } from "@/features/auth/services/auth.store";
@ -22,12 +22,20 @@ export function usePaymentRefresh({
hasMethods, hasMethods,
}: UsePaymentRefreshOptions) { }: UsePaymentRefreshOptions) {
const { isAuthenticated } = useAuthSession(); const { isAuthenticated } = useAuthSession();
const hideToastTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({ const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({
visible: false, visible: false,
text: "", text: "",
tone: "info", tone: "info",
}); });
const clearHideToastTimeout = () => {
if (hideToastTimeout.current) {
clearTimeout(hideToastTimeout.current);
hideToastTimeout.current = null;
}
};
const triggerRefresh = useCallback(async () => { const triggerRefresh = useCallback(async () => {
// Don't trigger refresh if not authenticated // Don't trigger refresh if not authenticated
if (!isAuthenticated) { if (!isAuthenticated) {
@ -57,10 +65,20 @@ export function usePaymentRefresh({
} catch { } catch {
setToast({ visible: true, text: "Could not refresh payment methods", tone: "warning" }); setToast({ visible: true, text: "Could not refresh payment methods", tone: "warning" });
} finally { } finally {
setTimeout(() => setToast(t => ({ ...t, visible: false })), 2200); clearHideToastTimeout();
hideToastTimeout.current = window.setTimeout(() => {
setToast(t => ({ ...t, visible: false }));
hideToastTimeout.current = null;
}, 2200);
} }
}, [isAuthenticated, refetch, hasMethods]); }, [isAuthenticated, refetch, hasMethods]);
useEffect(() => {
return () => {
clearHideToastTimeout();
};
}, []);
useEffect(() => { useEffect(() => {
if (!attachFocusListeners) return; if (!attachFocusListeners) return;

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
interface SimFeatureTogglesProps { interface SimFeatureTogglesProps {
@ -41,6 +41,7 @@ export function SimFeatureToggles({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
setVm(initial.vm); setVm(initial.vm);
@ -50,6 +51,10 @@ export function SimFeatureToggles({
}, [initial.vm, initial.cw, initial.ir, initial.nt]); }, [initial.vm, initial.cw, initial.ir, initial.nt]);
const reset = () => { const reset = () => {
if (successTimerRef.current) {
clearTimeout(successTimerRef.current);
successTimerRef.current = null;
}
setVm(initial.vm); setVm(initial.vm);
setCw(initial.cw); setCw(initial.cw);
setIr(initial.ir); setIr(initial.ir);
@ -87,10 +92,25 @@ export function SimFeatureToggles({
setError(e instanceof Error ? e.message : "Failed to submit changes"); setError(e instanceof Error ? e.message : "Failed to submit changes");
} finally { } finally {
setLoading(false); setLoading(false);
setTimeout(() => setSuccess(null), 3000); if (successTimerRef.current) {
clearTimeout(successTimerRef.current);
}
successTimerRef.current = window.setTimeout(() => {
setSuccess(null);
successTimerRef.current = null;
}, 3000);
} }
}; };
useEffect(() => {
return () => {
if (successTimerRef.current) {
clearTimeout(successTimerRef.current);
successTimerRef.current = null;
}
};
}, []);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Service Options */} {/* Service Options */}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback, useRef } from "react";
import { import {
DevicePhoneMobileIcon, DevicePhoneMobileIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
@ -21,13 +21,30 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
const [simInfo, setSimInfo] = useState<SimInfo | null>(null); const [simInfo, setSimInfo] = useState<SimInfo | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const isMountedRef = useRef(true);
useEffect(() => {
return () => {
isMountedRef.current = false;
abortControllerRef.current?.abort();
};
}, []);
const fetchSimInfo = useCallback(async () => { const fetchSimInfo = useCallback(async () => {
try { abortControllerRef.current?.abort();
setError(null); const controller = new AbortController();
abortControllerRef.current = controller;
if (isMountedRef.current) {
setLoading(true);
setError(null);
}
try {
const response = await apiClient.GET<SimInfo>("/api/subscriptions/{id}/sim", { const response = await apiClient.GET<SimInfo>("/api/subscriptions/{id}/sim", {
params: { path: { id: subscriptionId } }, params: { path: { id: subscriptionId } },
signal: controller.signal,
}); });
if (!response.data) { if (!response.data) {
@ -36,8 +53,15 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
const payload = simInfoSchema.parse(response.data); const payload = simInfoSchema.parse(response.data);
if (controller.signal.aborted || !isMountedRef.current) {
return;
}
setSimInfo(payload); setSimInfo(payload);
} catch (err: unknown) { } catch (err: unknown) {
if (controller.signal.aborted || !isMountedRef.current) {
return;
}
const hasStatus = (value: unknown): value is { status: number } => const hasStatus = (value: unknown): value is { status: number } =>
typeof value === "object" && typeof value === "object" &&
value !== null && value !== null &&
@ -52,6 +76,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
setError(err instanceof Error ? err.message : "Failed to load SIM information"); setError(err instanceof Error ? err.message : "Failed to load SIM information");
} finally { } finally {
if (controller.signal.aborted || !isMountedRef.current) {
return;
}
setLoading(false); setLoading(false);
} }
}, [subscriptionId]); }, [subscriptionId]);
@ -61,7 +88,6 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
}, [fetchSimInfo]); }, [fetchSimInfo]);
const handleRefresh = () => { const handleRefresh = () => {
setLoading(true);
void fetchSimInfo(); void fetchSimInfo();
}; };