mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-22 05:28:45 -05:00
perf(contacts): build segment WHERE clauses sequentially to prevent pool saturation (#7354)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
committed by
GitHub
parent
44f8f80cac
commit
fee770358c
@@ -52,54 +52,41 @@ export const getPersonSegmentIds = async (
|
||||
return [];
|
||||
}
|
||||
|
||||
// Phase 1: Build all Prisma where clauses concurrently.
|
||||
// This converts segment filters into where clauses without per-contact DB queries.
|
||||
const segmentWithClauses = await Promise.all(
|
||||
segments.map(async (segment) => {
|
||||
const filters = segment.filters as TBaseFilters | null;
|
||||
|
||||
if (!filters || filters.length === 0) {
|
||||
return { segmentId: segment.id, whereClause: {} as Prisma.ContactWhereInput };
|
||||
}
|
||||
|
||||
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
|
||||
|
||||
if (!queryResult.ok) {
|
||||
logger.warn(
|
||||
{ segmentId: segment.id, environmentId, error: queryResult.error },
|
||||
"Failed to build Prisma query for segment"
|
||||
);
|
||||
return { segmentId: segment.id, whereClause: null };
|
||||
}
|
||||
|
||||
return { segmentId: segment.id, whereClause: queryResult.data.whereClause };
|
||||
})
|
||||
);
|
||||
|
||||
// Separate segments into: always-match (no filters), needs-DB-check, and failed-to-build
|
||||
// Phase 1: Build WHERE clauses sequentially to avoid connection pool contention.
|
||||
// segmentFilterToPrismaQuery can itself hit the DB (e.g. unmigrated-row checks),
|
||||
// so running all builds concurrently would saturate the pool.
|
||||
const alwaysMatchIds: string[] = [];
|
||||
const toCheck: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
|
||||
const dbChecks: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
|
||||
|
||||
for (const item of segmentWithClauses) {
|
||||
if (item.whereClause === null) {
|
||||
for (const segment of segments) {
|
||||
const filters = segment.filters as TBaseFilters;
|
||||
|
||||
if (!filters?.length) {
|
||||
alwaysMatchIds.push(segment.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Object.keys(item.whereClause).length === 0) {
|
||||
alwaysMatchIds.push(item.segmentId);
|
||||
} else {
|
||||
toCheck.push({ segmentId: item.segmentId, whereClause: item.whereClause });
|
||||
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
|
||||
|
||||
if (!queryResult.ok) {
|
||||
logger.warn(
|
||||
{ segmentId: segment.id, environmentId, error: queryResult.error },
|
||||
"Failed to build Prisma query for segment, skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
dbChecks.push({ segmentId: segment.id, whereClause: queryResult.data.whereClause });
|
||||
}
|
||||
|
||||
if (toCheck.length === 0) {
|
||||
if (dbChecks.length === 0) {
|
||||
return alwaysMatchIds;
|
||||
}
|
||||
|
||||
// Phase 2: Batch all contact-match checks into a single DB transaction.
|
||||
// Replaces N individual findFirst queries with one batched round-trip.
|
||||
const batchResults = await prisma.$transaction(
|
||||
toCheck.map(({ whereClause }) =>
|
||||
// Phase 2: Execute all membership checks in a single transaction.
|
||||
// Uses one connection instead of N concurrent ones, eliminating pool contention.
|
||||
const txResults = await prisma.$transaction(
|
||||
dbChecks.map(({ whereClause }) =>
|
||||
prisma.contact.findFirst({
|
||||
where: { id: contactId, ...whereClause },
|
||||
select: { id: true },
|
||||
@@ -107,17 +94,12 @@ export const getPersonSegmentIds = async (
|
||||
)
|
||||
);
|
||||
|
||||
// Phase 3: Collect matching segment IDs
|
||||
const dbMatchIds = toCheck.filter((_, i) => batchResults[i] !== null).map(({ segmentId }) => segmentId);
|
||||
const matchedIds = dbChecks.filter((_, i) => txResults[i] !== null).map(({ segmentId }) => segmentId);
|
||||
|
||||
return [...alwaysMatchIds, ...dbMatchIds];
|
||||
return [...alwaysMatchIds, ...matchedIds];
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
environmentId,
|
||||
contactId,
|
||||
error,
|
||||
},
|
||||
{ environmentId, contactId, error },
|
||||
"Failed to get person segment IDs, returning empty array"
|
||||
);
|
||||
return [];
|
||||
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
import { getPersonSegmentIds } from "./segments";
|
||||
import { getUserState } from "./user-state";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./segments", () => ({
|
||||
getPersonSegmentIds: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "test-environment-id";
|
||||
const mockUserId = "test-user-id";
|
||||
const mockContactId = "test-contact-id";
|
||||
const mockDevice = "desktop";
|
||||
|
||||
describe("getUserState", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should return user state with empty responses and displays", async () => {
|
||||
const mockContactData = {
|
||||
id: mockContactId,
|
||||
responses: [],
|
||||
displays: [],
|
||||
};
|
||||
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
|
||||
vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment1"]);
|
||||
|
||||
const result = await getUserState({
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
contactId: mockContactId,
|
||||
device: mockDevice,
|
||||
});
|
||||
|
||||
expect(prisma.contact.findUniqueOrThrow).toHaveBeenCalledWith({
|
||||
where: { id: mockContactId },
|
||||
select: {
|
||||
id: true,
|
||||
responses: {
|
||||
select: { surveyId: true },
|
||||
},
|
||||
displays: {
|
||||
select: { surveyId: true, createdAt: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getPersonSegmentIds).toHaveBeenCalledWith(
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockUserId,
|
||||
mockDevice
|
||||
);
|
||||
expect(result).toEqual<TJsPersonState["data"]>({
|
||||
contactId: mockContactId,
|
||||
userId: mockUserId,
|
||||
segments: ["segment1"],
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return user state with responses and displays, and sort displays by createdAt", async () => {
|
||||
const mockDate1 = new Date("2023-01-01T00:00:00.000Z");
|
||||
const mockDate2 = new Date("2023-01-02T00:00:00.000Z");
|
||||
|
||||
const mockContactData = {
|
||||
id: mockContactId,
|
||||
responses: [{ surveyId: "survey1" }, { surveyId: "survey2" }],
|
||||
displays: [
|
||||
{ surveyId: "survey4", createdAt: mockDate2 }, // most recent (already sorted by desc)
|
||||
{ surveyId: "survey3", createdAt: mockDate1 },
|
||||
],
|
||||
};
|
||||
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
|
||||
vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment2", "segment3"]);
|
||||
|
||||
const result = await getUserState({
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
contactId: mockContactId,
|
||||
device: mockDevice,
|
||||
});
|
||||
|
||||
expect(result).toEqual<TJsPersonState["data"]>({
|
||||
contactId: mockContactId,
|
||||
userId: mockUserId,
|
||||
segments: ["segment2", "segment3"],
|
||||
displays: [
|
||||
{ surveyId: "survey4", createdAt: mockDate2 },
|
||||
{ surveyId: "survey3", createdAt: mockDate1 },
|
||||
],
|
||||
responses: ["survey1", "survey2"],
|
||||
lastDisplayAt: mockDate2,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle empty arrays from prisma", async () => {
|
||||
// This case tests with proper empty arrays instead of null
|
||||
const mockContactData = {
|
||||
id: mockContactId,
|
||||
responses: [],
|
||||
displays: [],
|
||||
};
|
||||
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
|
||||
vi.mocked(getPersonSegmentIds).mockResolvedValue([]);
|
||||
|
||||
const result = await getUserState({
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
contactId: mockContactId,
|
||||
device: mockDevice,
|
||||
});
|
||||
|
||||
expect(result).toEqual<TJsPersonState["data"]>({
|
||||
contactId: mockContactId,
|
||||
userId: mockUserId,
|
||||
segments: [],
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
import { getPersonSegmentIds } from "./segments";
|
||||
|
||||
/**
|
||||
* Optimized single query to get all user state data
|
||||
* Replaces multiple separate queries with one efficient query
|
||||
*/
|
||||
const getUserStateDataOptimized = async (contactId: string) => {
|
||||
return prisma.contact.findUniqueOrThrow({
|
||||
where: { id: contactId },
|
||||
select: {
|
||||
id: true,
|
||||
responses: {
|
||||
select: { surveyId: true },
|
||||
},
|
||||
displays: {
|
||||
select: {
|
||||
surveyId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Optimized user state fetcher without caching
|
||||
* Uses single database query and efficient data processing
|
||||
* NO CACHING - user state changes frequently with contact updates
|
||||
*
|
||||
* @param environmentId - The environment id
|
||||
* @param userId - The user id
|
||||
* @param device - The device type
|
||||
* @returns The person state
|
||||
* @throws {ValidationError} - If the input is invalid
|
||||
* @throws {ResourceNotFoundError} - If the environment or organization is not found
|
||||
*/
|
||||
export const getUserState = async ({
|
||||
environmentId,
|
||||
userId,
|
||||
contactId,
|
||||
device,
|
||||
}: {
|
||||
environmentId: string;
|
||||
userId: string;
|
||||
contactId: string;
|
||||
device: "phone" | "desktop";
|
||||
}): Promise<TJsPersonState["data"]> => {
|
||||
// Single optimized query for all contact data
|
||||
const contactData = await getUserStateDataOptimized(contactId);
|
||||
|
||||
// Get segments using Prisma-based evaluation (no attributes needed - fetched from DB)
|
||||
const segments = await getPersonSegmentIds(environmentId, contactId, userId, device);
|
||||
|
||||
// Process displays efficiently
|
||||
const displays = (contactData.displays ?? []).map((display) => ({
|
||||
surveyId: display.surveyId,
|
||||
createdAt: display.createdAt,
|
||||
}));
|
||||
|
||||
// Get latest display date
|
||||
const lastDisplayAt =
|
||||
contactData.displays && contactData.displays.length > 0 ? contactData.displays[0].createdAt : null;
|
||||
|
||||
// Process responses efficiently
|
||||
const responses = (contactData.responses ?? []).map((response) => response.surveyId);
|
||||
|
||||
const userState: TJsPersonState["data"] = {
|
||||
contactId,
|
||||
userId,
|
||||
segments,
|
||||
displays,
|
||||
responses,
|
||||
lastDisplayAt,
|
||||
};
|
||||
|
||||
return userState;
|
||||
};
|
||||
Reference in New Issue
Block a user