2025-09-06 10:01:44 +09:00
|
|
|
|
import { Injectable, OnModuleInit, OnModuleDestroy, Inject } from "@nestjs/common";
|
|
|
|
|
|
import { ConfigService } from "@nestjs/config";
|
|
|
|
|
|
import { Logger } from "nestjs-pino";
|
|
|
|
|
|
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
|
|
|
|
|
|
import { SalesforceConnection } from "../services/salesforce-connection.service";
|
2025-09-18 14:52:26 +09:00
|
|
|
|
import { ProvisioningQueueService } from "@bff/modules/orders/queue/provisioning.queue";
|
2025-09-17 18:43:43 +09:00
|
|
|
|
import { CacheService } from "@bff/infra/cache/cache.service";
|
2025-09-06 10:01:44 +09:00
|
|
|
|
import {
|
|
|
|
|
|
replayKey as sfReplayKey,
|
|
|
|
|
|
statusKey as sfStatusKey,
|
|
|
|
|
|
latestSeenKey as sfLatestSeenKey,
|
|
|
|
|
|
} from "./event-keys.util";
|
2025-09-25 16:23:24 +09:00
|
|
|
|
import type {
|
|
|
|
|
|
SalesforcePubSubEvent,
|
|
|
|
|
|
SalesforcePubSubError,
|
|
|
|
|
|
SalesforcePubSubSubscription,
|
|
|
|
|
|
SalesforcePubSubCallbackType,
|
2025-09-25 18:59:07 +09:00
|
|
|
|
SalesforcePubSubUnknownData,
|
2025-09-25 16:23:24 +09:00
|
|
|
|
} from "../types/pubsub-events.types";
|
2025-09-06 10:01:44 +09:00
|
|
|
|
|
|
|
|
|
|
type SubscribeCallback = (
|
2025-09-25 16:23:24 +09:00
|
|
|
|
subscription: SalesforcePubSubSubscription,
|
|
|
|
|
|
callbackType: SalesforcePubSubCallbackType,
|
2025-09-25 18:59:07 +09:00
|
|
|
|
data: SalesforcePubSubEvent | SalesforcePubSubError | SalesforcePubSubUnknownData
|
2025-09-06 10:01:44 +09:00
|
|
|
|
) => void | Promise<void>;
|
|
|
|
|
|
|
|
|
|
|
|
interface PubSubClient {
|
|
|
|
|
|
connect(): Promise<void>;
|
2025-09-06 13:58:54 +09:00
|
|
|
|
subscribe(topic: string, cb: SubscribeCallback, numRequested?: number): Promise<void>;
|
2025-09-06 10:01:44 +09:00
|
|
|
|
subscribeFromReplayId(
|
|
|
|
|
|
topic: string,
|
|
|
|
|
|
cb: SubscribeCallback,
|
2025-09-06 13:58:54 +09:00
|
|
|
|
numRequested: number | null,
|
2025-09-06 10:01:44 +09:00
|
|
|
|
replayId: number
|
|
|
|
|
|
): Promise<void>;
|
|
|
|
|
|
subscribeFromEarliestEvent(
|
|
|
|
|
|
topic: string,
|
|
|
|
|
|
cb: SubscribeCallback,
|
2025-09-06 13:58:54 +09:00
|
|
|
|
numRequested?: number
|
2025-09-06 10:01:44 +09:00
|
|
|
|
): Promise<void>;
|
|
|
|
|
|
requestAdditionalEvents(topic: string, numRequested: number): Promise<void>;
|
|
|
|
|
|
close(): Promise<void>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type PubSubCtor = new (opts: {
|
|
|
|
|
|
authType: string;
|
|
|
|
|
|
accessToken: string;
|
|
|
|
|
|
instanceUrl: string;
|
|
|
|
|
|
pubSubEndpoint: string;
|
|
|
|
|
|
}) => PubSubClient;
|
|
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
|
export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy {
|
|
|
|
|
|
private client: PubSubClient | null = null;
|
2025-09-26 15:51:07 +09:00
|
|
|
|
private clientAccessToken: string | null = null;
|
2025-09-06 10:01:44 +09:00
|
|
|
|
private channel!: string;
|
2025-09-11 13:17:10 +09:00
|
|
|
|
private replayCorruptionRecovered = false;
|
|
|
|
|
|
private subscribeCallback!: SubscribeCallback;
|
2025-09-26 15:51:07 +09:00
|
|
|
|
private pubSubCtor: PubSubCtor | null = null;
|
|
|
|
|
|
private clientBuildInFlight: Promise<PubSubClient> | null = null;
|
2025-09-06 10:01:44 +09:00
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
|
private readonly config: ConfigService,
|
|
|
|
|
|
private readonly sfConn: SalesforceConnection,
|
|
|
|
|
|
private readonly provisioningQueue: ProvisioningQueueService,
|
|
|
|
|
|
private readonly cache: CacheService,
|
|
|
|
|
|
@Inject(Logger) private readonly logger: Logger
|
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
|
|
async onModuleInit(): Promise<void> {
|
|
|
|
|
|
const enabled = this.config.get("SF_EVENTS_ENABLED", "false") === "true";
|
|
|
|
|
|
if (!enabled) {
|
|
|
|
|
|
this.logger.log("Salesforce Pub/Sub subscriber disabled", { enabled });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.channel = this.config.get<string>(
|
|
|
|
|
|
"SF_PROVISION_EVENT_CHANNEL",
|
|
|
|
|
|
"/event/Order_Fulfilment_Requested__e"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2025-09-26 15:51:07 +09:00
|
|
|
|
this.subscribeCallback = this.buildSubscribeCallback();
|
|
|
|
|
|
await this.subscribeWithPolicy(true);
|
2025-09-06 10:01:44 +09:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
this.logger.error("Salesforce Pub/Sub subscription failed", {
|
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
|
});
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.cache.set(sfStatusKey(this.channel || "/event/OrderProvisionRequested__e"), {
|
|
|
|
|
|
status: "disconnected",
|
|
|
|
|
|
since: Date.now(),
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (cacheErr) {
|
|
|
|
|
|
this.logger.warn("Failed to set SF Pub/Sub disconnected status", {
|
|
|
|
|
|
error: cacheErr instanceof Error ? cacheErr.message : String(cacheErr),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async onModuleDestroy(): Promise<void> {
|
|
|
|
|
|
try {
|
2025-09-26 15:51:07 +09:00
|
|
|
|
await this.safeCloseClient();
|
2025-09-06 10:01:44 +09:00
|
|
|
|
await this.cache.set(sfStatusKey(this.channel), {
|
|
|
|
|
|
status: "disconnected",
|
|
|
|
|
|
since: Date.now(),
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
this.logger.warn("Error closing Salesforce Pub/Sub client", {
|
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-26 15:51:07 +09:00
|
|
|
|
private buildSubscribeCallback(): SubscribeCallback {
|
|
|
|
|
|
return async (subscription, callbackType, data) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const argTypes = [typeof subscription, typeof callbackType, typeof data];
|
|
|
|
|
|
const type = callbackType;
|
|
|
|
|
|
const typeNorm = String(type || "").toLowerCase();
|
|
|
|
|
|
const topic = subscription.topicName || this.channel;
|
|
|
|
|
|
|
|
|
|
|
|
if (typeNorm === "data" || typeNorm === "event") {
|
|
|
|
|
|
const event = data as SalesforcePubSubEvent;
|
|
|
|
|
|
this.logger.debug("SF Pub/Sub data callback received", {
|
|
|
|
|
|
topic,
|
|
|
|
|
|
argTypes,
|
|
|
|
|
|
hasPayload: Boolean(event?.payload),
|
|
|
|
|
|
});
|
|
|
|
|
|
const payload = event?.payload;
|
|
|
|
|
|
|
|
|
|
|
|
const orderIdVal = payload?.["OrderId__c"] ?? payload?.["OrderId"];
|
|
|
|
|
|
const orderId = typeof orderIdVal === "string" ? orderIdVal : undefined;
|
|
|
|
|
|
if (!orderId) {
|
|
|
|
|
|
this.logger.warn("Pub/Sub event missing OrderId__c; skipping", {
|
|
|
|
|
|
argTypes,
|
|
|
|
|
|
topic,
|
|
|
|
|
|
payloadKeys: payload ? Object.keys(payload) : [],
|
|
|
|
|
|
});
|
|
|
|
|
|
const depth = await this.provisioningQueue.depth();
|
|
|
|
|
|
if (depth < this.getMaxQueueSize()) {
|
|
|
|
|
|
const activeClient = this.client;
|
|
|
|
|
|
if (activeClient) {
|
|
|
|
|
|
await activeClient.requestAdditionalEvents(topic, 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const replayVal = (event as { replayId?: unknown })?.replayId;
|
|
|
|
|
|
const idempotencyKey =
|
|
|
|
|
|
typeof replayVal === "number" || typeof replayVal === "string"
|
|
|
|
|
|
? String(replayVal)
|
|
|
|
|
|
: String(Date.now());
|
|
|
|
|
|
const pubsubReplayId = typeof replayVal === "number" ? replayVal : undefined;
|
|
|
|
|
|
|
|
|
|
|
|
await this.provisioningQueue.enqueue({
|
|
|
|
|
|
sfOrderId: orderId,
|
|
|
|
|
|
idempotencyKey,
|
|
|
|
|
|
pubsubReplayId,
|
|
|
|
|
|
});
|
|
|
|
|
|
this.logger.log("Enqueued provisioning job from SF event", {
|
|
|
|
|
|
sfOrderId: orderId,
|
|
|
|
|
|
replayId: pubsubReplayId,
|
|
|
|
|
|
topic,
|
|
|
|
|
|
});
|
|
|
|
|
|
} else if (typeNorm === "lastevent") {
|
|
|
|
|
|
const depth = await this.provisioningQueue.depth();
|
|
|
|
|
|
const available = Math.max(0, this.getMaxQueueSize() - depth);
|
|
|
|
|
|
const desired = Math.max(0, Math.min(this.getNumRequested(), available));
|
|
|
|
|
|
if (desired > 0) {
|
|
|
|
|
|
const activeClient = this.client;
|
|
|
|
|
|
if (activeClient) {
|
|
|
|
|
|
await activeClient.requestAdditionalEvents(topic, desired);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (typeNorm === "grpckeepalive") {
|
|
|
|
|
|
const latestVal = (data as { latestReplayId?: unknown })?.latestReplayId;
|
|
|
|
|
|
const latest = typeof latestVal === "number" ? latestVal : undefined;
|
|
|
|
|
|
if (typeof latest === "number") {
|
|
|
|
|
|
await this.cache.set(sfLatestSeenKey(this.channel), {
|
|
|
|
|
|
id: String(latest),
|
|
|
|
|
|
at: Date.now(),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (typeNorm === "grpcstatus" || typeNorm === "end") {
|
|
|
|
|
|
// Informational – no action required
|
|
|
|
|
|
} else if (typeNorm === "error") {
|
|
|
|
|
|
this.logger.warn("SF Pub/Sub stream error", { topic, data });
|
|
|
|
|
|
try {
|
|
|
|
|
|
const errorData = data as SalesforcePubSubError;
|
|
|
|
|
|
const details = errorData.details || "";
|
|
|
|
|
|
const metadata = errorData.metadata || {};
|
2025-09-26 16:30:00 +09:00
|
|
|
|
const errorCodes = Array.isArray(metadata["error-code"]) ? metadata["error-code"] : [];
|
2025-09-26 15:51:07 +09:00
|
|
|
|
const hasCorruptionCode = errorCodes.some(code =>
|
|
|
|
|
|
String(code).includes("replayid.corrupted")
|
|
|
|
|
|
);
|
|
|
|
|
|
const mentionsReplayValidation = /Replay ID validation failed/i.test(details);
|
|
|
|
|
|
|
2025-09-26 16:30:00 +09:00
|
|
|
|
if (
|
|
|
|
|
|
(hasCorruptionCode || mentionsReplayValidation) &&
|
|
|
|
|
|
!this.replayCorruptionRecovered
|
|
|
|
|
|
) {
|
2025-09-26 15:51:07 +09:00
|
|
|
|
this.replayCorruptionRecovered = true;
|
|
|
|
|
|
const key = sfReplayKey(this.channel);
|
|
|
|
|
|
await this.cache.del(key);
|
|
|
|
|
|
this.logger.warn(
|
|
|
|
|
|
"Cleared invalid Salesforce Pub/Sub replay cursor; retrying subscription",
|
|
|
|
|
|
{
|
|
|
|
|
|
channel: this.channel,
|
|
|
|
|
|
key,
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (recoveryErr) {
|
|
|
|
|
|
this.logger.warn("SF Pub/Sub replay corruption auto-recovery failed", {
|
|
|
|
|
|
error: recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr),
|
|
|
|
|
|
});
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
await this.recoverFromStreamError();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const maybeEvent = data as SalesforcePubSubEvent | undefined;
|
|
|
|
|
|
const hasPayload = Boolean(maybeEvent?.payload);
|
|
|
|
|
|
this.logger.debug("SF Pub/Sub callback ignored (unknown type)", {
|
|
|
|
|
|
type,
|
|
|
|
|
|
topic,
|
|
|
|
|
|
argTypes,
|
|
|
|
|
|
hasPayload,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
this.logger.error("Pub/Sub subscribe callback failed", {
|
|
|
|
|
|
error: err instanceof Error ? err.message : String(err),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private getNumRequested(): number {
|
|
|
|
|
|
return Number(this.config.get("SF_PUBSUB_NUM_REQUESTED", "50")) || 50;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private getMaxQueueSize(): number {
|
|
|
|
|
|
return Number(this.config.get("SF_PUBSUB_QUEUE_MAX", "100")) || 100;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private getPubSubCtor(): PubSubCtor {
|
|
|
|
|
|
if (this.pubSubCtor) {
|
|
|
|
|
|
return this.pubSubCtor;
|
|
|
|
|
|
}
|
|
|
|
|
|
const maybeCtor =
|
|
|
|
|
|
(PubSubApiClientPkg as { default?: unknown })?.default ?? (PubSubApiClientPkg as unknown);
|
|
|
|
|
|
this.pubSubCtor = maybeCtor as PubSubCtor;
|
|
|
|
|
|
return this.pubSubCtor;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async ensureClient(forceRefresh = false): Promise<PubSubClient> {
|
|
|
|
|
|
if (this.clientBuildInFlight && !forceRefresh) {
|
|
|
|
|
|
return this.clientBuildInFlight;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.clientBuildInFlight = (async () => {
|
|
|
|
|
|
await this.sfConn.ensureConnected();
|
|
|
|
|
|
const accessToken = this.sfConn.getAccessToken();
|
|
|
|
|
|
const instanceUrl = this.sfConn.getInstanceUrl();
|
|
|
|
|
|
if (!accessToken || !instanceUrl) {
|
|
|
|
|
|
throw new Error("Salesforce access token || instance URL unavailable");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const tokenChanged = this.clientAccessToken !== accessToken;
|
|
|
|
|
|
|
|
|
|
|
|
if (!this.client || forceRefresh || tokenChanged) {
|
|
|
|
|
|
await this.safeCloseClient();
|
2025-09-11 13:17:10 +09:00
|
|
|
|
|
2025-09-26 15:51:07 +09:00
|
|
|
|
const endpoint = this.config.get<string>(
|
|
|
|
|
|
"SF_PUBSUB_ENDPOINT",
|
|
|
|
|
|
"api.pubsub.salesforce.com:7443"
|
|
|
|
|
|
);
|
|
|
|
|
|
const Ctor = this.getPubSubCtor();
|
|
|
|
|
|
const client = new Ctor({
|
|
|
|
|
|
authType: "user-supplied",
|
|
|
|
|
|
accessToken,
|
|
|
|
|
|
instanceUrl,
|
|
|
|
|
|
pubSubEndpoint: endpoint,
|
|
|
|
|
|
});
|
2025-09-11 13:17:10 +09:00
|
|
|
|
|
2025-09-26 15:51:07 +09:00
|
|
|
|
await client.connect();
|
|
|
|
|
|
this.client = client;
|
|
|
|
|
|
this.clientAccessToken = accessToken;
|
|
|
|
|
|
this.replayCorruptionRecovered = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-26 16:30:00 +09:00
|
|
|
|
return this.client;
|
2025-09-26 15:51:07 +09:00
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await this.clientBuildInFlight;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
this.clientBuildInFlight = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async subscribeWithPolicy(forceClientRefresh = false): Promise<void> {
|
|
|
|
|
|
if (!this.subscribeCallback) {
|
|
|
|
|
|
throw new Error("Subscribe callback not initialized");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await this.cache.set(sfStatusKey(this.channel), {
|
|
|
|
|
|
status: "connecting",
|
|
|
|
|
|
since: Date.now(),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const client = await this.ensureClient(forceClientRefresh);
|
|
|
|
|
|
|
|
|
|
|
|
const replayMode = this.config.get<string>("SF_EVENTS_REPLAY", "LATEST");
|
|
|
|
|
|
const replayKey = sfReplayKey(this.channel);
|
|
|
|
|
|
const storedReplay = replayMode !== "ALL" ? await this.cache.get<string>(replayKey) : null;
|
|
|
|
|
|
const numRequested = this.getNumRequested();
|
|
|
|
|
|
|
|
|
|
|
|
if (storedReplay && replayMode !== "ALL") {
|
|
|
|
|
|
await client.subscribeFromReplayId(
|
2025-09-11 13:17:10 +09:00
|
|
|
|
this.channel,
|
|
|
|
|
|
this.subscribeCallback,
|
|
|
|
|
|
numRequested,
|
|
|
|
|
|
Number(storedReplay)
|
|
|
|
|
|
);
|
2025-09-26 15:51:07 +09:00
|
|
|
|
} else if (replayMode === "ALL") {
|
2025-09-26 16:30:00 +09:00
|
|
|
|
await client.subscribeFromEarliestEvent(this.channel, this.subscribeCallback, numRequested);
|
2025-09-11 13:17:10 +09:00
|
|
|
|
} else {
|
2025-09-26 15:51:07 +09:00
|
|
|
|
await client.subscribe(this.channel, this.subscribeCallback, numRequested);
|
2025-09-11 13:17:10 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await this.cache.set(sfStatusKey(this.channel), {
|
|
|
|
|
|
status: "connected",
|
|
|
|
|
|
since: Date.now(),
|
|
|
|
|
|
});
|
|
|
|
|
|
this.logger.log("Salesforce Pub/Sub subscription active", { channel: this.channel });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-26 15:51:07 +09:00
|
|
|
|
private async recoverFromStreamError(): Promise<void> {
|
|
|
|
|
|
await this.cache.set(sfStatusKey(this.channel), {
|
|
|
|
|
|
status: "reconnecting",
|
|
|
|
|
|
since: Date.now(),
|
|
|
|
|
|
});
|
|
|
|
|
|
await this.safeCloseClient();
|
|
|
|
|
|
await this.subscribeWithPolicy(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async safeCloseClient(): Promise<void> {
|
|
|
|
|
|
if (!this.client) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.client.close();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
this.logger.warn("Failed to close Salesforce Pub/Sub client", {
|
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
|
});
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
this.client = null;
|
|
|
|
|
|
this.clientAccessToken = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-09-06 10:01:44 +09:00
|
|
|
|
}
|