Files
plunk/test/performance/pagination.perf.test.ts
2026-01-01 10:52:43 +01:00

316 lines
11 KiB
TypeScript

import {describe, it, expect, beforeEach} from 'vitest';
import {ContactService} from '../../apps/api/src/services/ContactService';
import {SegmentService} from '../../apps/api/src/services/SegmentService';
import {factories, getPrismaClient} from '../helpers';
/**
* Performance tests for cursor-based pagination at scale
* Critical for CLAUDE.md requirement: "Database Performance: Queries must be optimized for large datasets (1M+ rows)"
*/
describe('Performance: Cursor Pagination at Scale', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
describe('ContactService - Large Dataset Pagination', () => {
it('should paginate through 10k contacts without loading all into memory', async () => {
// Create 10,000 contacts
const CONTACT_COUNT = 10000;
await factories.createContacts(projectId, CONTACT_COUNT);
let totalFetched = 0;
let cursor: string | undefined;
const pageSize = 1000;
const initialMemory = process.memoryUsage().heapUsed;
// Paginate through all contacts using cursor
while (true) {
const result = await ContactService.list(projectId, pageSize, cursor);
totalFetched += result.data.length;
if (!result.hasMore) break;
cursor = result.cursor;
}
const finalMemory = process.memoryUsage().heapUsed;
const memoryIncrease = (finalMemory - initialMemory) / 1024 / 1024; // MB
expect(totalFetched).toBe(CONTACT_COUNT);
expect(memoryIncrease).toBeLessThan(100); // Max 100MB increase
}, 60000); // 60s timeout for large dataset
it('should return correct hasMore flag at end of dataset', async () => {
await factories.createContacts(projectId, 25);
const pageSize = 10;
const page1 = await ContactService.list(projectId, pageSize);
expect(page1.data).toHaveLength(10);
expect(page1.hasMore).toBe(true);
const page2 = await ContactService.list(projectId, pageSize, page1.cursor);
expect(page2.data).toHaveLength(10);
expect(page2.hasMore).toBe(true);
const page3 = await ContactService.list(projectId, pageSize, page2.cursor);
expect(page3.data).toHaveLength(5);
expect(page3.hasMore).toBe(false);
});
it('should handle pagination with search filter efficiently', async () => {
// Create contacts with specific pattern
await factories.createContacts(projectId, 1000);
// Create 100 contacts with searchable email
for (let i = 0; i < 100; i++) {
await factories.createContact({
projectId,
email: `vip${i}@example.com`,
});
}
let totalFetched = 0;
let cursor: string | undefined;
const pageSize = 20;
// Paginate through filtered results
while (true) {
const result = await ContactService.list(projectId, pageSize, cursor, 'vip');
totalFetched += result.data.length;
if (!result.hasMore) break;
cursor = result.cursor;
}
expect(totalFetched).toBe(100);
}, 30000);
it('should only count total on first page for performance', async () => {
await factories.createContacts(projectId, 1000);
const page1 = await ContactService.list(projectId, 100);
expect(page1.total).toBeGreaterThan(0);
const page2 = await ContactService.list(projectId, 100, page1.cursor);
expect(page2.total).toBe(0);
}, 30000);
});
describe('SegmentService - Large Membership Computation', () => {
it('should compute membership for 10k contacts using cursor pagination', async () => {
// Create 10,000 contacts (half subscribed)
const CONTACT_COUNT = 10000;
await factories.createContacts(projectId, CONTACT_COUNT);
// Create segment tracking subscribed users
const segment = await factories.createSegment(projectId, {
name: 'Subscribed Users',
filters: [{field: 'subscribed', operator: 'equals', value: true}],
trackMembership: true,
});
const initialMemory = process.memoryUsage().heapUsed;
const result = await SegmentService.computeMembership(projectId, segment.id);
const finalMemory = process.memoryUsage().heapUsed;
const memoryIncrease = (finalMemory - initialMemory) / 1024 / 1024; // MB
expect(result.total).toBeGreaterThan(0);
expect(result.total).toBeLessThanOrEqual(CONTACT_COUNT);
// Should not load all contacts into memory
expect(memoryIncrease).toBeLessThan(150); // Max 150MB increase
}, 120000); // 120s timeout
it('should batch membership additions in chunks of 500', async () => {
// Create 2000 contacts
await factories.createContacts(projectId, 2000);
const segment = await factories.createSegment(projectId, {
filters: [{field: 'subscribed', operator: 'equals', value: true}],
trackMembership: true,
});
await SegmentService.computeMembership(projectId, segment.id);
const memberships = await prisma.segmentMembership.count({
where: {segmentId: segment.id},
});
expect(memberships).toBeGreaterThan(0);
}, 60000);
it('should handle membership removal efficiently when contacts no longer match', async () => {
// Create 1000 subscribed contacts
const contacts = [];
for (let i = 0; i < 1000; i++) {
const contact = await factories.createContact({
projectId,
subscribed: true,
});
contacts.push(contact);
}
const segment = await factories.createSegment(projectId, {
filters: [{field: 'subscribed', operator: 'equals', value: true}],
trackMembership: true,
});
// Initial computation
const initial = await SegmentService.computeMembership(projectId, segment.id);
expect(initial.added).toBe(1000);
// Unsubscribe half the contacts
for (let i = 0; i < 500; i++) {
await prisma.contact.update({
where: {id: contacts[i].id},
data: {subscribed: false},
});
}
// Recompute - should remove 500
const updated = await SegmentService.computeMembership(projectId, segment.id);
expect(updated.removed).toBe(500);
expect(updated.total).toBe(500);
}, 90000);
});
describe('SegmentService - Query Performance', () => {
it('should get contacts in segment under 200ms for 10k dataset', async () => {
// Per CLAUDE.md: "API Response Times: Target < 200ms for read operations"
await factories.createContacts(projectId, 10000);
const segment = await factories.createSegment(projectId, {
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
const start = Date.now();
const result = await SegmentService.getContacts(projectId, segment.id, 1, 20);
const duration = Date.now() - start;
expect(result.data.length).toBeLessThanOrEqual(20);
expect(duration).toBeLessThan(200); // < 200ms target
}, 30000);
it('should handle offset pagination without OOM for large results', async () => {
await factories.createContacts(projectId, 5000);
const segment = await factories.createSegment(projectId, {
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
const page1 = await SegmentService.getContacts(projectId, segment.id, 1, 100);
const page10 = await SegmentService.getContacts(projectId, segment.id, 10, 100);
const page50 = await SegmentService.getContacts(projectId, segment.id, 50, 100);
expect(page1.data.length).toBeLessThanOrEqual(100);
expect(page10.data.length).toBeLessThanOrEqual(100);
expect(page50.data.length).toBeLessThanOrEqual(100);
}, 45000);
});
describe('Memory Efficiency Validation', () => {
it('should not accumulate memory when paginating repeatedly', async () => {
await factories.createContacts(projectId, 5000);
const measureMemory = () => {
if (global.gc) global.gc(); // Force GC if available
return process.memoryUsage().heapUsed / 1024 / 1024; // MB
};
const initialMemory = measureMemory();
// Paginate 10 times
for (let i = 0; i < 10; i++) {
let cursor: string | undefined;
while (true) {
const result = await ContactService.list(projectId, 500, cursor);
if (!result.hasMore) break;
cursor = result.cursor;
}
}
const finalMemory = measureMemory();
const memoryGrowth = finalMemory - initialMemory;
// Memory should not grow significantly with repeated pagination
expect(memoryGrowth).toBeLessThan(50); // Max 50MB growth
}, 90000);
});
describe('Pagination Edge Cases', () => {
it('should handle empty dataset gracefully', async () => {
const result = await ContactService.list(projectId, 20);
expect(result.data).toHaveLength(0);
expect(result.hasMore).toBe(false);
expect(result.cursor).toBeUndefined();
expect(result.total).toBe(0);
});
it('should handle dataset smaller than page size', async () => {
await factories.createContacts(projectId, 5);
const result = await ContactService.list(projectId, 20);
expect(result.data).toHaveLength(5);
expect(result.hasMore).toBe(false);
expect(result.cursor).toBeUndefined();
});
it('should handle exact page size boundary', async () => {
await factories.createContacts(projectId, 20);
const result = await ContactService.list(projectId, 20);
expect(result.data).toHaveLength(20);
expect(result.hasMore).toBe(false);
});
it('should handle one item over page size', async () => {
await factories.createContacts(projectId, 21);
const page1 = await ContactService.list(projectId, 20);
expect(page1.data).toHaveLength(20);
expect(page1.hasMore).toBe(true);
const page2 = await ContactService.list(projectId, 20, page1.cursor);
expect(page2.data).toHaveLength(1);
expect(page2.hasMore).toBe(false);
});
it('should handle concurrent pagination requests', async () => {
await factories.createContacts(projectId, 1000);
// Multiple workers paginating simultaneously
const promises = Array.from({length: 5}, async () => {
let totalFetched = 0;
let cursor: string | undefined;
while (true) {
const result = await ContactService.list(projectId, 100, cursor);
totalFetched += result.data.length;
if (!result.hasMore) break;
cursor = result.cursor;
}
return totalFetched;
});
const results = await Promise.all(promises);
// All should fetch the same total
expect(results.every(count => count === results[0])).toBe(true);
expect(results[0]).toBe(1000);
}, 30000);
});
});