perf: merge CMPP inbox validation and persistence
This commit is contained in:
@@ -33,6 +33,26 @@ type ClaimedInboundWorkflow = {
|
||||
payload: Prisma.JsonValue;
|
||||
};
|
||||
|
||||
type PersistedInboundWorkflowRow = {
|
||||
validationError: string | null;
|
||||
payloadHash: string | null;
|
||||
response: Prisma.JsonValue | null;
|
||||
};
|
||||
|
||||
type InboundApplication = NonNullable<Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>>;
|
||||
|
||||
type InboundWorkflowResponse = {
|
||||
accepted: boolean;
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
taskId: string;
|
||||
messageId: string;
|
||||
messageRecordId: string;
|
||||
status: string;
|
||||
phoneCount: number;
|
||||
messages: Array<{ phoneNumber: string; messageId: string; messageRecordId: string; taskId: string; status: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* R9 inboundEntry implementation. Cross-method calls return through the stable SendChainService seam.
|
||||
*/
|
||||
@@ -183,6 +203,13 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) {
|
||||
throw new BadRequestException('CMPP submit phone number is invalid');
|
||||
}
|
||||
|
||||
if (this.inboundFastPathEnabled() && !data.longMessage) {
|
||||
return this.measureInboundStage(
|
||||
'inbox_persist',
|
||||
() => this.persistValidatedInboundWorkflow(data, phoneNumbers),
|
||||
);
|
||||
}
|
||||
|
||||
const application = await this.measureInboundStage(
|
||||
'application_lookup',
|
||||
() => this.facade.findInboundApplication(data.account),
|
||||
@@ -276,12 +303,6 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (this.inboundFastPathEnabled()) {
|
||||
return this.measureInboundStage(
|
||||
'inbox_persist',
|
||||
() => this.persistInboundWorkflow(data, phoneNumbers, application),
|
||||
);
|
||||
}
|
||||
return this.measureInboundStage(
|
||||
'complete_submit',
|
||||
() => this.facade.submitCompleteInboundMessage(data, phoneNumbers, application),
|
||||
@@ -292,6 +313,162 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) {
|
||||
return process.env.CMPP_INBOUND_FAST_PATH_ENABLED === 'true';
|
||||
}
|
||||
|
||||
private async persistValidatedInboundWorkflow(
|
||||
data: GatewayInboundSubmitDto,
|
||||
phoneNumbers: string[],
|
||||
) {
|
||||
const requestKey = data.requestId?.trim();
|
||||
if (!requestKey || requestKey.length > 160) {
|
||||
throw new BadRequestException('CMPP inbound requestId is required for fast-path idempotency');
|
||||
}
|
||||
const submitGroupMessageId = `MSG-${randomUUID()}`;
|
||||
const messageIds = phoneNumbers.map((_, index) => index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`);
|
||||
const payload: InboundWorkflowPayload = {
|
||||
data: JSON.parse(JSON.stringify(data)) as GatewayInboundSubmitDto,
|
||||
phoneNumbers,
|
||||
submitGroupMessageId,
|
||||
messageIds,
|
||||
};
|
||||
const payloadHash = createHash('sha256').update(JSON.stringify({
|
||||
data: payload.data,
|
||||
phoneNumbers,
|
||||
requestedGroupMessageId: null,
|
||||
})).digest('hex');
|
||||
const responseMessages = phoneNumbers.map((phoneNumber, index) => ({
|
||||
phoneNumber,
|
||||
messageId: messageIds[index],
|
||||
messageRecordId: '',
|
||||
taskId: '',
|
||||
status: 'accepted_pending',
|
||||
}));
|
||||
const remoteIp = data.remoteIp?.replace(/^::ffff:/, '').trim() || null;
|
||||
const submittedSrcId = data.srcId?.trim() ?? '';
|
||||
|
||||
// One indexed statement must both validate the current application state and create the
|
||||
// durable Inbox row. Keeping those operations in one database snapshot prevents a disable
|
||||
// racing between a separate SELECT and INSERT, while the unique request key remains the
|
||||
// authoritative idempotency boundary.
|
||||
const rows = await this.prisma.$queryRaw<PersistedInboundWorkflowRow[]>(Prisma.sql`
|
||||
WITH application AS (
|
||||
SELECT app.id,
|
||||
app."tenantId",
|
||||
app.status,
|
||||
app."interfaceEnabled",
|
||||
app."queuePriority",
|
||||
app."cmppApplicationExtension",
|
||||
app."cmppAccessNumberFillEnabled",
|
||||
app."cmppAccessNumberFillPrefix",
|
||||
app."cmppClientSrcId",
|
||||
tenant.status AS "tenantStatus"
|
||||
FROM "SmsApplication" AS app
|
||||
JOIN "Tenant" AS tenant ON tenant.id = app."tenantId"
|
||||
WHERE app."cmppAccount" = ${data.account}
|
||||
LIMIT 1
|
||||
), validation AS (
|
||||
SELECT application.*,
|
||||
CASE
|
||||
WHEN application.status <> 'active'
|
||||
OR application."tenantStatus" <> 'active'
|
||||
OR NOT application."interfaceEnabled"
|
||||
THEN 'CMPP account is disabled for new submissions'
|
||||
WHEN ${remoteIp}::text IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "SmsApplicationIpAllowlist" allowlist
|
||||
WHERE allowlist."applicationId" = application.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "SmsApplicationIpAllowlist" allowlist
|
||||
WHERE allowlist."applicationId" = application.id
|
||||
AND (
|
||||
(position('/' IN trim(allowlist."ipCidr")) = 0
|
||||
AND regexp_replace(trim(allowlist."ipCidr"), '^::ffff:', '') = ${remoteIp})
|
||||
OR (
|
||||
trim(allowlist."ipCidr") ~ '^[0-9]{1,3}(\\.[0-9]{1,3}){3}/([0-9]|[12][0-9]|3[0-2])$'
|
||||
AND ${remoteIp} ~ '^[0-9]{1,3}(\\.[0-9]{1,3}){3}$'
|
||||
AND ${remoteIp}::inet <<= trim(allowlist."ipCidr")::cidr
|
||||
)
|
||||
)
|
||||
)
|
||||
THEN 'CMPP source IP is not in application allowlist'
|
||||
WHEN coalesce(trim(application."cmppApplicationExtension"), '') <> ''
|
||||
AND ${submittedSrcId} <> coalesce(
|
||||
nullif(trim(application."cmppClientSrcId"), ''),
|
||||
CASE WHEN application."cmppAccessNumberFillEnabled"
|
||||
THEN coalesce(trim(application."cmppAccessNumberFillPrefix"), '')
|
||||
ELSE ''
|
||||
END || trim(application."cmppApplicationExtension")
|
||||
)
|
||||
THEN 'CMPP Src_Id must equal the access number assigned to this application: ' || coalesce(
|
||||
nullif(trim(application."cmppClientSrcId"), ''),
|
||||
CASE WHEN application."cmppAccessNumberFillEnabled"
|
||||
THEN coalesce(trim(application."cmppAccessNumberFillPrefix"), '')
|
||||
ELSE ''
|
||||
END || trim(application."cmppApplicationExtension")
|
||||
)
|
||||
ELSE NULL
|
||||
END AS "validationError"
|
||||
FROM application
|
||||
), inserted AS (
|
||||
INSERT INTO "CmppInboundSubmissionInbox" (
|
||||
id, "requestKey", "payloadHash", "tenantId", "applicationId", "queuePriority",
|
||||
payload, response, status, attempts, "nextAttemptAt", "createdAt", "updatedAt"
|
||||
)
|
||||
SELECT ${randomUUID()}, ${requestKey}, ${payloadHash}, validation."tenantId", validation.id,
|
||||
CASE WHEN validation."queuePriority" = 'priority' THEN 'priority' ELSE 'normal' END,
|
||||
${JSON.stringify(payload)}::jsonb,
|
||||
jsonb_build_object(
|
||||
'accepted', true,
|
||||
'tenantId', validation."tenantId",
|
||||
'applicationId', validation.id,
|
||||
'taskId', '',
|
||||
'messageId', ${submitGroupMessageId},
|
||||
'messageRecordId', '',
|
||||
'status', 'accepted_pending',
|
||||
'phoneCount', ${phoneNumbers.length},
|
||||
'messages', ${JSON.stringify(responseMessages)}::jsonb
|
||||
),
|
||||
'pending', 0,
|
||||
(NOW() AT TIME ZONE 'UTC'),
|
||||
(NOW() AT TIME ZONE 'UTC'),
|
||||
(NOW() AT TIME ZONE 'UTC')
|
||||
FROM validation
|
||||
WHERE validation."validationError" IS NULL
|
||||
ON CONFLICT ("requestKey") DO NOTHING
|
||||
RETURNING "payloadHash", response
|
||||
)
|
||||
SELECT validation."validationError",
|
||||
coalesce(inserted."payloadHash", existing."payloadHash") AS "payloadHash",
|
||||
coalesce(inserted.response, existing.response) AS response
|
||||
FROM validation
|
||||
LEFT JOIN inserted ON true
|
||||
LEFT JOIN "CmppInboundSubmissionInbox" existing
|
||||
ON existing."requestKey" = ${requestKey}
|
||||
LIMIT 1
|
||||
`);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
throw new BadRequestException('CMPP account is invalid');
|
||||
}
|
||||
if (row.validationError) {
|
||||
throw new BadRequestException(row.validationError);
|
||||
}
|
||||
if (row.response == null) {
|
||||
// A concurrent insert that wins after this statement's MVCC snapshot is not visible to
|
||||
// the CTE. The unique key has already prevented duplication, so only that rare retry path
|
||||
// needs a second read; normal submissions still use exactly one database round trip.
|
||||
const concurrent = await this.prisma.cmppInboundSubmissionInbox.findUnique({ where: { requestKey } });
|
||||
if (!concurrent || concurrent.payloadHash !== payloadHash) {
|
||||
throw new BadRequestException('CMPP inbound requestId conflicts with another payload');
|
||||
}
|
||||
return concurrent.response as InboundWorkflowResponse;
|
||||
}
|
||||
if (row.payloadHash !== payloadHash) {
|
||||
throw new BadRequestException('CMPP inbound requestId conflicts with another payload');
|
||||
}
|
||||
return row.response as InboundWorkflowResponse;
|
||||
}
|
||||
|
||||
private async persistInboundWorkflow(
|
||||
data: GatewayInboundSubmitDto,
|
||||
phoneNumbers: string[],
|
||||
@@ -1051,10 +1228,17 @@ startInboundWorkflowWorker() {
|
||||
try {
|
||||
await this.refreshInboundWorkflowMetrics();
|
||||
const claimed = await this.claimInboundWorkflows(available);
|
||||
const applications = claimed.length === 0
|
||||
? []
|
||||
: await this.prisma.smsApplication.findMany({
|
||||
where: { id: { in: [...new Set(claimed.map((item) => item.applicationId))] } },
|
||||
include: { tenant: true, ipAllowlist: true },
|
||||
});
|
||||
const applicationById = new Map(applications.map((application) => [application.id, application]));
|
||||
for (const item of claimed) {
|
||||
this.inboundWorkflowInFlight += 1;
|
||||
this.metrics?.setInboundWorkflowSlots(concurrency, this.inboundWorkflowInFlight);
|
||||
const task = this.processClaimedInboundWorkflow(item)
|
||||
const task = this.processClaimedInboundWorkflow(item, applicationById.get(item.applicationId))
|
||||
.catch((error) => this.logger.error(`CMPP inbound workflow ${item.id} failed to settle: ${String(error)}`))
|
||||
.finally(() => {
|
||||
this.inboundWorkflowInFlight = Math.max(0, this.inboundWorkflowInFlight - 1);
|
||||
@@ -1110,11 +1294,10 @@ startInboundWorkflowWorker() {
|
||||
`);
|
||||
}
|
||||
|
||||
private async processClaimedInboundWorkflow(item: ClaimedInboundWorkflow) {
|
||||
private async processClaimedInboundWorkflow(item: ClaimedInboundWorkflow, application?: InboundApplication) {
|
||||
try {
|
||||
const payload = parseInboundWorkflowPayload(item.payload);
|
||||
const application = await this.facade.findInboundApplication(payload.data.account);
|
||||
if (!application || application.id !== item.applicationId) {
|
||||
if (!application || application.cmppAccount !== payload.data.account) {
|
||||
throw new Error('CMPP inbound application no longer matches persisted workflow');
|
||||
}
|
||||
const result = await this.facade.submitCompleteInboundMessage(
|
||||
|
||||
Reference in New Issue
Block a user