einvoice/test/suite/einvoice_performance/test.perf-04.conversion-throughput.ts
2025-05-25 19:45:37 +00:00

583 lines
22 KiB
TypeScript

/**
* @file test.perf-04.conversion-throughput.ts
* @description Performance tests for format conversion throughput
*/
import { tap } from '@git.zone/tstest/tapbundle';
import * as plugins from '../../plugins.js';
import { EInvoice } from '../../../ts/index.js';
import { CorpusLoader } from '../../suite/corpus.loader.js';
import { PerformanceTracker } from '../../suite/performance.tracker.js';
const corpusLoader = new CorpusLoader();
const performanceTracker = new PerformanceTracker('PERF-04: Conversion Throughput');
tap.test('PERF-04: Conversion Throughput - should achieve target throughput for format conversions', async (t) => {
// Test 1: Single-threaded conversion throughput
const singleThreadThroughput = await performanceTracker.measureAsync(
'single-thread-throughput',
async () => {
const einvoice = new EInvoice();
const results = {
conversions: [],
totalTime: 0,
totalInvoices: 0,
totalBytes: 0
};
// Create test invoices of varying complexity
const testInvoices = [
// Simple invoice
...Array(20).fill(null).map((_, i) => ({
format: 'ubl' as const,
targetFormat: 'cii' as const,
complexity: 'simple',
data: {
documentType: 'INVOICE',
invoiceNumber: `SIMPLE-${i + 1}`,
issueDate: '2024-02-05',
seller: { name: 'Simple Seller', address: 'Address', country: 'US', taxId: 'US123' },
buyer: { name: 'Simple Buyer', address: 'Address', country: 'US', taxId: 'US456' },
items: [{ description: 'Item', quantity: 1, unitPrice: 100, vatRate: 10, lineTotal: 100 }],
totals: { netAmount: 100, vatAmount: 10, grossAmount: 110 }
}
})),
// Medium complexity
...Array(10).fill(null).map((_, i) => ({
format: 'cii' as const,
targetFormat: 'ubl' as const,
complexity: 'medium',
data: {
documentType: 'INVOICE',
invoiceNumber: `MEDIUM-${i + 1}`,
issueDate: '2024-02-05',
dueDate: '2024-03-05',
seller: {
name: 'Medium Complexity Seller GmbH',
address: 'Hauptstraße 123',
city: 'Berlin',
postalCode: '10115',
country: 'DE',
taxId: 'DE123456789'
},
buyer: {
name: 'Medium Complexity Buyer Ltd',
address: 'Business Street 456',
city: 'Munich',
postalCode: '80331',
country: 'DE',
taxId: 'DE987654321'
},
items: Array.from({ length: 10 }, (_, j) => ({
description: `Product ${j + 1}`,
quantity: j + 1,
unitPrice: 50 + j * 10,
vatRate: 19,
lineTotal: (j + 1) * (50 + j * 10)
})),
totals: { netAmount: 1650, vatAmount: 313.50, grossAmount: 1963.50 }
}
})),
// Complex invoice
...Array(5).fill(null).map((_, i) => ({
format: 'ubl' as const,
targetFormat: 'zugferd' as const,
complexity: 'complex',
data: {
documentType: 'INVOICE',
invoiceNumber: `COMPLEX-${i + 1}`,
issueDate: '2024-02-05',
seller: {
name: 'Complex International Corporation',
address: 'Global Plaza 1',
city: 'New York',
country: 'US',
taxId: 'US12-3456789',
email: 'billing@complex.com',
phone: '+1-212-555-0100'
},
buyer: {
name: 'Complex Buyer Enterprises',
address: 'Commerce Center 2',
city: 'London',
country: 'GB',
taxId: 'GB123456789',
email: 'ap@buyer.co.uk'
},
items: Array.from({ length: 50 }, (_, j) => ({
description: `Complex Product ${j + 1} with detailed specifications`,
quantity: Math.floor(Math.random() * 20) + 1,
unitPrice: Math.random() * 500,
vatRate: [0, 5, 10, 20][Math.floor(Math.random() * 4)],
lineTotal: 0
})),
totals: { netAmount: 0, vatAmount: 0, grossAmount: 0 }
}
}))
];
// Calculate totals for complex invoices
testInvoices.filter(inv => inv.complexity === 'complex').forEach(invoice => {
invoice.data.items.forEach(item => {
item.lineTotal = item.quantity * item.unitPrice;
invoice.data.totals.netAmount += item.lineTotal;
invoice.data.totals.vatAmount += item.lineTotal * (item.vatRate / 100);
});
invoice.data.totals.grossAmount = invoice.data.totals.netAmount + invoice.data.totals.vatAmount;
});
// Process all conversions
const startTime = Date.now();
for (const testInvoice of testInvoices) {
const invoice = { format: testInvoice.format, data: testInvoice.data };
const invoiceSize = JSON.stringify(invoice).length;
const conversionStart = process.hrtime.bigint();
try {
const converted = await einvoice.convertFormat(invoice, testInvoice.targetFormat);
const conversionEnd = process.hrtime.bigint();
const duration = Number(conversionEnd - conversionStart) / 1_000_000;
results.conversions.push({
complexity: testInvoice.complexity,
from: testInvoice.format,
to: testInvoice.targetFormat,
duration,
size: invoiceSize,
success: true
});
results.totalBytes += invoiceSize;
} catch (error) {
results.conversions.push({
complexity: testInvoice.complexity,
from: testInvoice.format,
to: testInvoice.targetFormat,
duration: 0,
size: invoiceSize,
success: false
});
}
results.totalInvoices++;
}
results.totalTime = Date.now() - startTime;
// Calculate throughput metrics
const successfulConversions = results.conversions.filter(c => c.success);
const throughputStats = {
invoicesPerSecond: (successfulConversions.length / (results.totalTime / 1000)).toFixed(2),
bytesPerSecond: (results.totalBytes / (results.totalTime / 1000) / 1024).toFixed(2), // KB/s
avgConversionTime: successfulConversions.length > 0 ?
(successfulConversions.reduce((sum, c) => sum + c.duration, 0) / successfulConversions.length).toFixed(3) : 'N/A'
};
// Group by complexity
const complexityStats = ['simple', 'medium', 'complex'].map(complexity => {
const conversions = successfulConversions.filter(c => c.complexity === complexity);
return {
complexity,
count: conversions.length,
avgTime: conversions.length > 0 ?
(conversions.reduce((sum, c) => sum + c.duration, 0) / conversions.length).toFixed(3) : 'N/A'
};
});
return { ...results, throughputStats, complexityStats };
}
);
// Test 2: Parallel conversion throughput
const parallelThroughput = await performanceTracker.measureAsync(
'parallel-throughput',
async () => {
const einvoice = new EInvoice();
const results = [];
// Create a batch of invoices
const batchSize = 50;
const testInvoices = Array.from({ length: batchSize }, (_, i) => ({
format: i % 2 === 0 ? 'ubl' : 'cii' as const,
data: {
documentType: 'INVOICE',
invoiceNumber: `PARALLEL-${i + 1}`,
issueDate: '2024-02-05',
seller: { name: `Seller ${i + 1}`, address: 'Address', country: 'US', taxId: `US${i}` },
buyer: { name: `Buyer ${i + 1}`, address: 'Address', country: 'US', taxId: `US${i + 100}` },
items: Array.from({ length: 5 }, (_, j) => ({
description: `Item ${j + 1}`,
quantity: 1,
unitPrice: 100,
vatRate: 10,
lineTotal: 100
})),
totals: { netAmount: 500, vatAmount: 50, grossAmount: 550 }
}
}));
// Test different parallelism levels
const parallelismLevels = [1, 2, 5, 10, 20];
for (const parallelism of parallelismLevels) {
const startTime = Date.now();
let completed = 0;
let failed = 0;
// Process in batches
for (let i = 0; i < testInvoices.length; i += parallelism) {
const batch = testInvoices.slice(i, i + parallelism);
const conversionPromises = batch.map(async (invoice) => {
try {
const targetFormat = invoice.format === 'ubl' ? 'cii' : 'ubl';
await einvoice.convertFormat(invoice, targetFormat);
return true;
} catch {
return false;
}
});
const batchResults = await Promise.all(conversionPromises);
completed += batchResults.filter(r => r).length;
failed += batchResults.filter(r => !r).length;
}
const totalTime = Date.now() - startTime;
const throughput = (completed / (totalTime / 1000)).toFixed(2);
results.push({
parallelism,
totalTime,
completed,
failed,
throughput: `${throughput} conversions/sec`,
avgTimePerConversion: (totalTime / batchSize).toFixed(3)
});
}
return results;
}
);
// Test 3: Corpus conversion throughput
const corpusThroughput = await performanceTracker.measureAsync(
'corpus-throughput',
async () => {
const files = await corpusLoader.getFilesByPattern('**/*.xml');
const einvoice = new EInvoice();
const results = {
formatPairs: new Map<string, { count: number; totalTime: number; totalSize: number }>(),
overallStats: {
totalConversions: 0,
successfulConversions: 0,
totalTime: 0,
totalBytes: 0
}
};
// Sample corpus files
const sampleFiles = files.slice(0, 40);
const startTime = Date.now();
for (const file of sampleFiles) {
try {
const content = await plugins.fs.readFile(file, 'utf-8');
const fileSize = Buffer.byteLength(content, 'utf-8');
// Detect and parse
const format = await einvoice.detectFormat(content);
if (!format || format === 'unknown') continue;
const invoice = await einvoice.parseInvoice(content, format);
// Determine target format
const targetFormat = format === 'ubl' ? 'cii' :
format === 'cii' ? 'ubl' :
format === 'zugferd' ? 'xrechnung' : 'ubl';
const pairKey = `${format}->${targetFormat}`;
// Measure conversion
const conversionStart = process.hrtime.bigint();
try {
await einvoice.convertFormat(invoice, targetFormat);
const conversionEnd = process.hrtime.bigint();
const duration = Number(conversionEnd - conversionStart) / 1_000_000;
// Update statistics
if (!results.formatPairs.has(pairKey)) {
results.formatPairs.set(pairKey, { count: 0, totalTime: 0, totalSize: 0 });
}
const pairStats = results.formatPairs.get(pairKey)!;
pairStats.count++;
pairStats.totalTime += duration;
pairStats.totalSize += fileSize;
results.overallStats.successfulConversions++;
results.overallStats.totalBytes += fileSize;
} catch (error) {
// Conversion failed
}
results.overallStats.totalConversions++;
} catch (error) {
// File processing failed
}
}
results.overallStats.totalTime = Date.now() - startTime;
// Calculate throughput by format pair
const formatPairStats = Array.from(results.formatPairs.entries()).map(([pair, stats]) => ({
pair,
count: stats.count,
avgTime: (stats.totalTime / stats.count).toFixed(3),
avgSize: (stats.totalSize / stats.count / 1024).toFixed(2), // KB
throughput: ((stats.totalSize / 1024) / (stats.totalTime / 1000)).toFixed(2) // KB/s
}));
return {
...results.overallStats,
successRate: ((results.overallStats.successfulConversions / results.overallStats.totalConversions) * 100).toFixed(1),
overallThroughput: {
invoicesPerSecond: (results.overallStats.successfulConversions / (results.overallStats.totalTime / 1000)).toFixed(2),
kbPerSecond: ((results.overallStats.totalBytes / 1024) / (results.overallStats.totalTime / 1000)).toFixed(2)
},
formatPairStats
};
}
);
// Test 4: Streaming conversion throughput
const streamingThroughput = await performanceTracker.measureAsync(
'streaming-throughput',
async () => {
const einvoice = new EInvoice();
const results = {
streamSize: 0,
processedInvoices: 0,
totalTime: 0,
peakMemory: 0,
errors: 0
};
// Simulate streaming scenario
const invoiceStream = Array.from({ length: 100 }, (_, i) => ({
format: 'ubl' as const,
data: {
documentType: 'INVOICE',
invoiceNumber: `STREAM-${i + 1}`,
issueDate: '2024-02-05',
seller: { name: `Stream Seller ${i + 1}`, address: 'Address', country: 'US', taxId: `US${i}` },
buyer: { name: `Stream Buyer ${i + 1}`, address: 'Address', country: 'US', taxId: `US${i + 1000}` },
items: Array.from({ length: Math.floor(Math.random() * 10) + 1 }, (_, j) => ({
description: `Stream Item ${j + 1}`,
quantity: Math.random() * 10,
unitPrice: Math.random() * 100,
vatRate: [5, 10, 20][Math.floor(Math.random() * 3)],
lineTotal: 0
})),
totals: { netAmount: 0, vatAmount: 0, grossAmount: 0 }
}
}));
// Calculate totals
invoiceStream.forEach(invoice => {
invoice.data.items.forEach(item => {
item.lineTotal = item.quantity * item.unitPrice;
invoice.data.totals.netAmount += item.lineTotal;
invoice.data.totals.vatAmount += item.lineTotal * (item.vatRate / 100);
});
invoice.data.totals.grossAmount = invoice.data.totals.netAmount + invoice.data.totals.vatAmount;
results.streamSize += JSON.stringify(invoice).length;
});
// Process stream
const startTime = Date.now();
const initialMemory = process.memoryUsage().heapUsed;
// Simulate streaming with chunks
const chunkSize = 10;
for (let i = 0; i < invoiceStream.length; i += chunkSize) {
const chunk = invoiceStream.slice(i, i + chunkSize);
// Process chunk in parallel
const chunkPromises = chunk.map(async (invoice) => {
try {
await einvoice.convertFormat(invoice, 'cii');
results.processedInvoices++;
} catch {
results.errors++;
}
});
await Promise.all(chunkPromises);
// Check memory usage
const currentMemory = process.memoryUsage().heapUsed;
if (currentMemory > results.peakMemory) {
results.peakMemory = currentMemory;
}
}
results.totalTime = Date.now() - startTime;
return {
...results,
throughput: {
invoicesPerSecond: (results.processedInvoices / (results.totalTime / 1000)).toFixed(2),
mbPerSecond: ((results.streamSize / 1024 / 1024) / (results.totalTime / 1000)).toFixed(2)
},
memoryIncreaseMB: ((results.peakMemory - initialMemory) / 1024 / 1024).toFixed(2),
successRate: ((results.processedInvoices / invoiceStream.length) * 100).toFixed(1)
};
}
);
// Test 5: Sustained throughput test
const sustainedThroughput = await performanceTracker.measureAsync(
'sustained-throughput',
async () => {
const einvoice = new EInvoice();
const testDuration = 10000; // 10 seconds
const results = {
secondlyThroughput: [],
totalConversions: 0,
minThroughput: Infinity,
maxThroughput: 0,
avgThroughput: 0
};
// Test invoice template
const testInvoice = {
format: 'ubl' as const,
data: {
documentType: 'INVOICE',
invoiceNumber: 'SUSTAINED-TEST',
issueDate: '2024-02-05',
seller: { name: 'Sustained Seller', address: 'Address', country: 'US', taxId: 'US123' },
buyer: { name: 'Sustained Buyer', address: 'Address', country: 'US', taxId: 'US456' },
items: [{ description: 'Item', quantity: 1, unitPrice: 100, vatRate: 10, lineTotal: 100 }],
totals: { netAmount: 100, vatAmount: 10, grossAmount: 110 }
}
};
const startTime = Date.now();
let currentSecond = 0;
let conversionsInCurrentSecond = 0;
while (Date.now() - startTime < testDuration) {
const elapsed = Date.now() - startTime;
const second = Math.floor(elapsed / 1000);
if (second > currentSecond) {
// Record throughput for completed second
results.secondlyThroughput.push(conversionsInCurrentSecond);
if (conversionsInCurrentSecond < results.minThroughput) {
results.minThroughput = conversionsInCurrentSecond;
}
if (conversionsInCurrentSecond > results.maxThroughput) {
results.maxThroughput = conversionsInCurrentSecond;
}
currentSecond = second;
conversionsInCurrentSecond = 0;
}
// Perform conversion
try {
await einvoice.convertFormat(testInvoice, 'cii');
conversionsInCurrentSecond++;
results.totalConversions++;
} catch {
// Conversion failed
}
}
// Calculate average
if (results.secondlyThroughput.length > 0) {
results.avgThroughput = results.secondlyThroughput.reduce((a, b) => a + b, 0) / results.secondlyThroughput.length;
}
return {
duration: Math.floor((Date.now() - startTime) / 1000),
totalConversions: results.totalConversions,
minThroughput: results.minThroughput === Infinity ? 0 : results.minThroughput,
maxThroughput: results.maxThroughput,
avgThroughput: results.avgThroughput.toFixed(2),
variance: results.secondlyThroughput.length > 0 ?
Math.sqrt(results.secondlyThroughput.reduce((sum, val) =>
sum + Math.pow(val - results.avgThroughput, 2), 0) / results.secondlyThroughput.length).toFixed(2) : 0
};
}
);
// Summary
t.comment('\n=== PERF-04: Conversion Throughput Test Summary ===');
t.comment('\nSingle-Thread Throughput:');
t.comment(` Total conversions: ${singleThreadThroughput.result.totalInvoices}`);
t.comment(` Successful: ${singleThreadThroughput.result.conversions.filter(c => c.success).length}`);
t.comment(` Total time: ${singleThreadThroughput.result.totalTime}ms`);
t.comment(` Throughput: ${singleThreadThroughput.result.throughputStats.invoicesPerSecond} invoices/sec`);
t.comment(` Data rate: ${singleThreadThroughput.result.throughputStats.bytesPerSecond} KB/sec`);
t.comment(' By complexity:');
singleThreadThroughput.result.complexityStats.forEach(stat => {
t.comment(` - ${stat.complexity}: ${stat.count} invoices, avg ${stat.avgTime}ms`);
});
t.comment('\nParallel Throughput:');
parallelThroughput.result.forEach(result => {
t.comment(` ${result.parallelism} parallel: ${result.throughput}, avg ${result.avgTimePerConversion}ms/conversion`);
});
t.comment('\nCorpus Throughput:');
t.comment(` Total conversions: ${corpusThroughput.result.totalConversions}`);
t.comment(` Success rate: ${corpusThroughput.result.successRate}%`);
t.comment(` Overall: ${corpusThroughput.result.overallThroughput.invoicesPerSecond} invoices/sec, ${corpusThroughput.result.overallThroughput.kbPerSecond} KB/sec`);
t.comment(' By format pair:');
corpusThroughput.result.formatPairStats.slice(0, 5).forEach(stat => {
t.comment(` - ${stat.pair}: ${stat.count} conversions, ${stat.throughput} KB/sec`);
});
t.comment('\nStreaming Throughput:');
t.comment(` Processed: ${streamingThroughput.result.processedInvoices}/${streamingThroughput.result.processedInvoices + streamingThroughput.result.errors} invoices`);
t.comment(` Success rate: ${streamingThroughput.result.successRate}%`);
t.comment(` Throughput: ${streamingThroughput.result.throughput.invoicesPerSecond} invoices/sec`);
t.comment(` Data rate: ${streamingThroughput.result.throughput.mbPerSecond} MB/sec`);
t.comment(` Peak memory increase: ${streamingThroughput.result.memoryIncreaseMB} MB`);
t.comment('\nSustained Throughput (10 seconds):');
t.comment(` Total conversions: ${sustainedThroughput.result.totalConversions}`);
t.comment(` Min throughput: ${sustainedThroughput.result.minThroughput} conversions/sec`);
t.comment(` Max throughput: ${sustainedThroughput.result.maxThroughput} conversions/sec`);
t.comment(` Avg throughput: ${sustainedThroughput.result.avgThroughput} conversions/sec`);
t.comment(` Std deviation: ${sustainedThroughput.result.variance}`);
// Performance targets check
t.comment('\n=== Performance Targets Check ===');
const avgThroughput = parseFloat(singleThreadThroughput.result.throughputStats.invoicesPerSecond);
const targetThroughput = 10; // Target: >10 conversions/sec
if (avgThroughput > targetThroughput) {
t.comment(`✅ Conversion throughput meets target: ${avgThroughput} > ${targetThroughput} conversions/sec`);
} else {
t.comment(`⚠️ Conversion throughput below target: ${avgThroughput} < ${targetThroughput} conversions/sec`);
}
// Overall performance summary
t.comment('\n=== Overall Performance Summary ===');
performanceTracker.logSummary();
t.end();
});
tap.start();