Initial push of Plunk Next
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
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.contacts.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.contacts).toHaveLength(10);
|
||||
expect(page1.hasMore).toBe(true);
|
||||
|
||||
const page2 = await ContactService.list(projectId, pageSize, page1.cursor);
|
||||
expect(page2.contacts).toHaveLength(10);
|
||||
expect(page2.hasMore).toBe(true);
|
||||
|
||||
const page3 = await ContactService.list(projectId, pageSize, page2.cursor);
|
||||
expect(page3.contacts).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.contacts.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.contacts.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.contacts.length).toBeLessThanOrEqual(100);
|
||||
expect(page10.contacts.length).toBeLessThanOrEqual(100);
|
||||
expect(page50.contacts.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.contacts).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.contacts).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.contacts).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.contacts).toHaveLength(20);
|
||||
expect(page1.hasMore).toBe(true);
|
||||
|
||||
const page2 = await ContactService.list(projectId, 20, page1.cursor);
|
||||
|
||||
expect(page2.contacts).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.contacts.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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,528 @@
|
||||
import {describe, it, expect, beforeEach} from 'vitest';
|
||||
import {WorkflowStepType, WorkflowExecutionStatus} from '@plunk/db';
|
||||
import {factories, getPrismaClient} from '../helpers';
|
||||
|
||||
/**
|
||||
* Performance Tests: Workflow Execution at Scale
|
||||
*
|
||||
* These tests verify that workflow operations perform efficiently
|
||||
* even with large numbers of executions and complex workflows.
|
||||
*
|
||||
* Performance Targets (from CLAUDE.md):
|
||||
* - Read operations: < 200ms
|
||||
* - Write operations: < 500ms
|
||||
* - Background jobs for bulk operations
|
||||
*/
|
||||
describe('Performance: Workflow Execution at Scale', () => {
|
||||
let projectId: string;
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// WORKFLOW EXECUTION RETRIEVAL
|
||||
// ========================================
|
||||
describe('Workflow Execution Queries', () => {
|
||||
it('should retrieve workflow executions under 200ms for 1000+ executions', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contacts = await factories.createContacts(projectId, 1000);
|
||||
|
||||
// Create 1000 executions
|
||||
await Promise.all(contacts.map(contact => factories.createWorkflowExecution(workflow.id, contact.id)));
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const executions = await prisma.workflowExecution.findMany({
|
||||
where: {workflowId: workflow.id},
|
||||
take: 20, // Paginated query
|
||||
include: {
|
||||
contact: {
|
||||
select: {email: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(executions).toHaveLength(20);
|
||||
expect(duration).toBeLessThan(200); // < 200ms target
|
||||
});
|
||||
|
||||
it('should filter running executions efficiently', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contacts = await factories.createContacts(projectId, 500);
|
||||
|
||||
// Create mix of statuses
|
||||
await Promise.all(
|
||||
contacts.slice(0, 250).map(contact =>
|
||||
factories.createWorkflowExecution(workflow.id, contact.id, {
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
contacts.slice(250).map(contact =>
|
||||
factories.createWorkflowExecution(workflow.id, contact.id, {
|
||||
status: WorkflowExecutionStatus.COMPLETED,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const running = await prisma.workflowExecution.findMany({
|
||||
where: {
|
||||
workflowId: workflow.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
},
|
||||
take: 20,
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(running).toHaveLength(20);
|
||||
expect(running.every(e => e.status === WorkflowExecutionStatus.RUNNING)).toBe(true);
|
||||
expect(duration).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('should count executions efficiently without loading all into memory', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contacts = await factories.createContacts(projectId, 2000);
|
||||
|
||||
await Promise.all(contacts.map(contact => factories.createWorkflowExecution(workflow.id, contact.id)));
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const count = await prisma.workflowExecution.count({
|
||||
where: {workflowId: workflow.id},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(count).toBe(2000);
|
||||
expect(duration).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// COMPLEX WORKFLOW QUERIES
|
||||
// ========================================
|
||||
describe('Complex Workflow Structures', () => {
|
||||
it('should handle workflows with many steps efficiently', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
|
||||
// Create workflow with 20 steps
|
||||
const steps = await Promise.all(
|
||||
Array.from({length: 20}, (_, i) =>
|
||||
factories.createWorkflowStep({
|
||||
workflowId: workflow.id,
|
||||
name: `Step ${i + 1}`,
|
||||
type: i % 2 === 0 ? WorkflowStepType.SEND_EMAIL : WorkflowStepType.DELAY,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Create transitions (linear chain)
|
||||
for (let i = 0; i < steps.length - 1; i++) {
|
||||
await prisma.workflowTransition.create({
|
||||
data: {
|
||||
fromStepId: steps[i].id,
|
||||
toStepId: steps[i + 1].id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const retrieved = await prisma.workflow.findUnique({
|
||||
where: {id: workflow.id},
|
||||
include: {
|
||||
steps: {
|
||||
include: {
|
||||
outgoingTransitions: true,
|
||||
incomingTransitions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(retrieved?.steps.length).toBeGreaterThanOrEqual(20); // At least 20 steps
|
||||
expect(duration).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('should handle branching workflows (CONDITION steps) efficiently', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
|
||||
// Create condition step
|
||||
const conditionStep = await factories.createWorkflowStep({
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.CONDITION,
|
||||
});
|
||||
|
||||
// Create branches (yes/no paths with multiple steps each)
|
||||
const yesSteps = await Promise.all(
|
||||
Array.from({length: 5}, () =>
|
||||
factories.createWorkflowStep({
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.SEND_EMAIL,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const noSteps = await Promise.all(
|
||||
Array.from({length: 5}, () =>
|
||||
factories.createWorkflowStep({
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.DELAY,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Create transitions
|
||||
await prisma.workflowTransition.create({
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: yesSteps[0].id,
|
||||
condition: {branch: 'yes'},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.workflowTransition.create({
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: noSteps[0].id,
|
||||
condition: {branch: 'no'},
|
||||
},
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const retrieved = await prisma.workflow.findUnique({
|
||||
where: {id: workflow.id},
|
||||
include: {
|
||||
steps: {
|
||||
include: {
|
||||
outgoingTransitions: {
|
||||
include: {
|
||||
toStep: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(retrieved?.steps.length).toBeGreaterThan(10);
|
||||
expect(duration).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// RE-ENTRY CHECKS
|
||||
// ========================================
|
||||
describe('Re-Entry Check Performance', () => {
|
||||
it('should check for existing executions efficiently (allowReentry=false)', async () => {
|
||||
const workflow = await factories.createWorkflow({
|
||||
projectId,
|
||||
allowReentry: false,
|
||||
});
|
||||
|
||||
const contacts = await factories.createContacts(projectId, 100);
|
||||
|
||||
// Create executions for all contacts
|
||||
await Promise.all(contacts.map(contact => factories.createWorkflowExecution(workflow.id, contact.id)));
|
||||
|
||||
// Measure time to check for existing execution
|
||||
const testContact = contacts[50];
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const existingExecution = await prisma.workflowExecution.findFirst({
|
||||
where: {
|
||||
workflowId: workflow.id,
|
||||
contactId: testContact.id,
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(existingExecution).toBeDefined();
|
||||
expect(duration).toBeLessThan(50); // Should be very fast with proper index
|
||||
});
|
||||
|
||||
it('should check for running executions efficiently (allowReentry=true)', async () => {
|
||||
const workflow = await factories.createWorkflow({
|
||||
projectId,
|
||||
allowReentry: true,
|
||||
});
|
||||
|
||||
const contacts = await factories.createContacts(projectId, 200);
|
||||
|
||||
// Create mix of running and completed executions
|
||||
await Promise.all(
|
||||
contacts.slice(0, 100).map(contact =>
|
||||
factories.createWorkflowExecution(workflow.id, contact.id, {
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
contacts.slice(100).map(contact =>
|
||||
factories.createWorkflowExecution(workflow.id, contact.id, {
|
||||
status: WorkflowExecutionStatus.COMPLETED,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const testContact = contacts[50];
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const runningExecution = await prisma.workflowExecution.findFirst({
|
||||
where: {
|
||||
workflowId: workflow.id,
|
||||
contactId: testContact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(runningExecution).toBeDefined();
|
||||
expect(duration).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// BULK OPERATIONS
|
||||
// ========================================
|
||||
describe('Bulk Workflow Operations', () => {
|
||||
it('should create multiple executions efficiently', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contacts = await factories.createContacts(projectId, 500);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Get the trigger step (created automatically by factory)
|
||||
const triggerStep = await prisma.workflowStep.findFirst({
|
||||
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
|
||||
});
|
||||
|
||||
// Batch create executions
|
||||
const executions = await prisma.workflowExecution.createMany({
|
||||
data: contacts.map(contact => ({
|
||||
workflowId: workflow.id,
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep!.id,
|
||||
})),
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(executions.count).toBe(500);
|
||||
expect(duration).toBeLessThan(500); // < 500ms for bulk write
|
||||
});
|
||||
|
||||
it('should cancel multiple executions efficiently', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contacts = await factories.createContacts(projectId, 300);
|
||||
|
||||
await Promise.all(
|
||||
contacts.map(contact =>
|
||||
factories.createWorkflowExecution(workflow.id, contact.id, {
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Bulk cancel all running executions
|
||||
const result = await prisma.workflowExecution.updateMany({
|
||||
where: {
|
||||
workflowId: workflow.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
},
|
||||
data: {
|
||||
status: WorkflowExecutionStatus.CANCELLED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(result.count).toBe(300);
|
||||
expect(duration).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// STEP EXECUTION PERFORMANCE
|
||||
// ========================================
|
||||
describe('Step Execution Tracking', () => {
|
||||
it('should create step executions efficiently', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contact = await factories.createContact({projectId});
|
||||
const execution = await factories.createWorkflowExecution(workflow.id, contact.id);
|
||||
|
||||
const steps = await Promise.all(
|
||||
Array.from({length: 10}, () =>
|
||||
factories.createWorkflowStep({
|
||||
workflowId: workflow.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Create step executions for all steps
|
||||
await prisma.workflowStepExecution.createMany({
|
||||
data: steps.map(step => ({
|
||||
executionId: execution.id,
|
||||
stepId: step.id,
|
||||
status: 'COMPLETED',
|
||||
output: null,
|
||||
})),
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(duration).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('should retrieve execution history with steps efficiently', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contact = await factories.createContact({projectId});
|
||||
const execution = await factories.createWorkflowExecution(workflow.id, contact.id);
|
||||
|
||||
// Create 20 step executions
|
||||
const steps = await Promise.all(
|
||||
Array.from({length: 20}, () =>
|
||||
factories.createWorkflowStep({
|
||||
workflowId: workflow.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
steps.map(step =>
|
||||
prisma.workflowStepExecution.create({
|
||||
data: {
|
||||
executionId: execution.id,
|
||||
stepId: step.id,
|
||||
status: 'COMPLETED',
|
||||
output: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const retrieved = await prisma.workflowExecution.findUnique({
|
||||
where: {id: execution.id},
|
||||
include: {
|
||||
stepExecutions: {
|
||||
include: {
|
||||
step: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(retrieved?.stepExecutions).toHaveLength(20);
|
||||
expect(duration).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// MEMORY EFFICIENCY
|
||||
// ========================================
|
||||
describe('Memory Efficiency', () => {
|
||||
it('should not load all executions when counting', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contacts = await factories.createContacts(projectId, 5000);
|
||||
|
||||
// Create 5000 executions
|
||||
await prisma.workflowExecution.createMany({
|
||||
data: contacts.map(contact => ({
|
||||
workflowId: workflow.id,
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.COMPLETED,
|
||||
})),
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Count without loading
|
||||
const count = await prisma.workflowExecution.count({
|
||||
where: {workflowId: workflow.id},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(count).toBe(5000);
|
||||
expect(duration).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('should paginate large result sets without OOM', async () => {
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const contacts = await factories.createContacts(projectId, 1000);
|
||||
|
||||
await prisma.workflowExecution.createMany({
|
||||
data: contacts.map(contact => ({
|
||||
workflowId: workflow.id,
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.COMPLETED,
|
||||
})),
|
||||
});
|
||||
|
||||
// Paginate through all results
|
||||
let cursor: string | undefined;
|
||||
let totalFetched = 0;
|
||||
const pageSize = 100;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
while (true) {
|
||||
const page = await prisma.workflowExecution.findMany({
|
||||
where: {workflowId: workflow.id},
|
||||
take: pageSize + 1,
|
||||
...(cursor ? {skip: 1, cursor: {id: cursor}} : {}),
|
||||
orderBy: {createdAt: 'asc'},
|
||||
});
|
||||
|
||||
if (page.length === 0) break;
|
||||
|
||||
const hasMore = page.length > pageSize;
|
||||
const items = hasMore ? page.slice(0, -1) : page;
|
||||
|
||||
totalFetched += items.length;
|
||||
|
||||
if (!hasMore) break;
|
||||
|
||||
cursor = page[page.length - 1].id;
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(totalFetched).toBeGreaterThanOrEqual(1000); // At least 1000, may include other test data
|
||||
expect(duration).toBeLessThan(2000); // Should complete in reasonable time
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user