update
This commit is contained in:
386
test/suite/einvoice_performance/test.perf-01.detection-speed.ts
Normal file
386
test/suite/einvoice_performance/test.perf-01.detection-speed.ts
Normal file
@ -0,0 +1,386 @@
|
||||
/**
|
||||
* @file test.perf-01.detection-speed.ts
|
||||
* @description Performance tests for format detection speed
|
||||
*/
|
||||
|
||||
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-01: Format Detection Speed');
|
||||
|
||||
tap.test('PERF-01: Format Detection Speed - should meet performance targets for format detection', async (t) => {
|
||||
// Test 1: Single file detection benchmarks
|
||||
const singleFileDetection = await performanceTracker.measureAsync(
|
||||
'single-file-detection',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const benchmarks = [];
|
||||
|
||||
// Test different format samples
|
||||
const testCases = [
|
||||
{
|
||||
name: 'Small UBL',
|
||||
content: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>TEST-001</ID>
|
||||
<IssueDate>2024-01-01</IssueDate>
|
||||
</Invoice>`,
|
||||
expectedFormat: 'ubl'
|
||||
},
|
||||
{
|
||||
name: 'Small CII',
|
||||
content: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100">
|
||||
<rsm:ExchangedDocument><ram:ID>TEST-002</ram:ID></rsm:ExchangedDocument>
|
||||
</rsm:CrossIndustryInvoice>`,
|
||||
expectedFormat: 'cii'
|
||||
},
|
||||
{
|
||||
name: 'Large UBL',
|
||||
content: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>TEST-003</ID>
|
||||
<IssueDate>2024-01-01</IssueDate>
|
||||
${Array(100).fill('<InvoiceLine><ID>Line</ID></InvoiceLine>').join('\n')}
|
||||
</Invoice>`,
|
||||
expectedFormat: 'ubl'
|
||||
}
|
||||
];
|
||||
|
||||
// Run multiple iterations for accuracy
|
||||
const iterations = 100;
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const times = [];
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const startTime = process.hrtime.bigint();
|
||||
const format = await einvoice.detectFormat(testCase.content);
|
||||
const endTime = process.hrtime.bigint();
|
||||
|
||||
const duration = Number(endTime - startTime) / 1_000_000; // Convert to ms
|
||||
times.push(duration);
|
||||
|
||||
if (i === 0 && format !== testCase.expectedFormat) {
|
||||
t.comment(`Warning: ${testCase.name} detected as ${format}, expected ${testCase.expectedFormat}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
times.sort((a, b) => a - b);
|
||||
const stats = {
|
||||
name: testCase.name,
|
||||
min: times[0],
|
||||
max: times[times.length - 1],
|
||||
avg: times.reduce((a, b) => a + b, 0) / times.length,
|
||||
median: times[Math.floor(times.length / 2)],
|
||||
p95: times[Math.floor(times.length * 0.95)],
|
||||
p99: times[Math.floor(times.length * 0.99)]
|
||||
};
|
||||
|
||||
benchmarks.push(stats);
|
||||
}
|
||||
|
||||
return benchmarks;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 2: Corpus detection performance
|
||||
const corpusDetection = await performanceTracker.measureAsync(
|
||||
'corpus-detection-performance',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
totalFiles: 0,
|
||||
detectionTimes: [],
|
||||
formatDistribution: new Map<string, number>(),
|
||||
sizeCategories: {
|
||||
small: { count: 0, avgTime: 0, times: [] }, // < 10KB
|
||||
medium: { count: 0, avgTime: 0, times: [] }, // 10-100KB
|
||||
large: { count: 0, avgTime: 0, times: [] }, // > 100KB
|
||||
},
|
||||
failures: 0
|
||||
};
|
||||
|
||||
// Process sample of corpus files
|
||||
const sampleFiles = files.slice(0, 100);
|
||||
|
||||
for (const file of sampleFiles) {
|
||||
try {
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
const fileSize = Buffer.byteLength(content, 'utf-8');
|
||||
const sizeCategory = fileSize < 10240 ? 'small' :
|
||||
fileSize < 102400 ? 'medium' : 'large';
|
||||
|
||||
results.totalFiles++;
|
||||
|
||||
// Measure detection time
|
||||
const startTime = process.hrtime.bigint();
|
||||
const format = await einvoice.detectFormat(content);
|
||||
const endTime = process.hrtime.bigint();
|
||||
const duration = Number(endTime - startTime) / 1_000_000;
|
||||
|
||||
results.detectionTimes.push(duration);
|
||||
results.sizeCategories[sizeCategory].times.push(duration);
|
||||
results.sizeCategories[sizeCategory].count++;
|
||||
|
||||
// Track format distribution
|
||||
if (format && format !== 'unknown') {
|
||||
results.formatDistribution.set(format,
|
||||
(results.formatDistribution.get(format) || 0) + 1
|
||||
);
|
||||
} else {
|
||||
results.failures++;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
results.failures++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
for (const category of Object.keys(results.sizeCategories)) {
|
||||
const cat = results.sizeCategories[category];
|
||||
if (cat.times.length > 0) {
|
||||
cat.avgTime = cat.times.reduce((a, b) => a + b, 0) / cat.times.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Overall statistics
|
||||
results.detectionTimes.sort((a, b) => a - b);
|
||||
const overallStats = {
|
||||
min: results.detectionTimes[0],
|
||||
max: results.detectionTimes[results.detectionTimes.length - 1],
|
||||
avg: results.detectionTimes.reduce((a, b) => a + b, 0) / results.detectionTimes.length,
|
||||
median: results.detectionTimes[Math.floor(results.detectionTimes.length / 2)],
|
||||
p95: results.detectionTimes[Math.floor(results.detectionTimes.length * 0.95)]
|
||||
};
|
||||
|
||||
return {
|
||||
...results,
|
||||
overallStats,
|
||||
formatDistribution: Array.from(results.formatDistribution.entries())
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Test 3: Concurrent detection performance
|
||||
const concurrentDetection = await performanceTracker.measureAsync(
|
||||
'concurrent-detection',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const concurrencyLevels = [1, 5, 10, 20, 50];
|
||||
const results = [];
|
||||
|
||||
// Create test content
|
||||
const testContent = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>CONCURRENT-TEST</ID>
|
||||
<IssueDate>2024-01-01</IssueDate>
|
||||
<AccountingSupplierParty><Party><PartyName><Name>Test Supplier</Name></PartyName></Party></AccountingSupplierParty>
|
||||
<AccountingCustomerParty><Party><PartyName><Name>Test Customer</Name></PartyName></Party></AccountingCustomerParty>
|
||||
</Invoice>`;
|
||||
|
||||
for (const concurrency of concurrencyLevels) {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Create concurrent detection tasks
|
||||
const tasks = Array(concurrency).fill(null).map(() =>
|
||||
einvoice.detectFormat(testContent)
|
||||
);
|
||||
|
||||
const detectionResults = await Promise.all(tasks);
|
||||
const endTime = Date.now();
|
||||
|
||||
const duration = endTime - startTime;
|
||||
const throughput = (concurrency / (duration / 1000)).toFixed(2);
|
||||
|
||||
results.push({
|
||||
concurrency,
|
||||
duration,
|
||||
throughput: `${throughput} detections/sec`,
|
||||
allSuccessful: detectionResults.every(r => r === 'ubl')
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 4: Edge case detection performance
|
||||
const edgeCaseDetection = await performanceTracker.measureAsync(
|
||||
'edge-case-detection',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const edgeCases = [
|
||||
{
|
||||
name: 'Minimal XML',
|
||||
content: '<?xml version="1.0"?><root/>'
|
||||
},
|
||||
{
|
||||
name: 'No XML declaration',
|
||||
content: '<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ID>1</ID></Invoice>'
|
||||
},
|
||||
{
|
||||
name: 'With comments',
|
||||
content: '<?xml version="1.0"?><!-- Comment --><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><!-- Another comment --><ID>1</ID></Invoice>'
|
||||
},
|
||||
{
|
||||
name: 'With processing instructions',
|
||||
content: '<?xml version="1.0"?><?xml-stylesheet type="text/xsl" href="style.xsl"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ID>1</ID></Invoice>'
|
||||
},
|
||||
{
|
||||
name: 'Mixed namespaces',
|
||||
content: '<?xml version="1.0"?><ns1:Invoice xmlns:ns1="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:ns2="http://example.com"><ns1:ID>1</ns1:ID></ns1:Invoice>'
|
||||
},
|
||||
{
|
||||
name: 'Large with whitespace',
|
||||
content: '<?xml version="1.0"?>\n\n\n' + ' '.repeat(10000) + '<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">\n' + ' '.repeat(5000) + '<ID>1</ID>\n' + ' '.repeat(5000) + '</Invoice>'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const edgeCase of edgeCases) {
|
||||
const times = [];
|
||||
const iterations = 50;
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const startTime = process.hrtime.bigint();
|
||||
const format = await einvoice.detectFormat(edgeCase.content);
|
||||
const endTime = process.hrtime.bigint();
|
||||
const duration = Number(endTime - startTime) / 1_000_000;
|
||||
times.push(duration);
|
||||
}
|
||||
|
||||
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
|
||||
results.push({
|
||||
name: edgeCase.name,
|
||||
avgTime: avgTime.toFixed(3),
|
||||
contentSize: edgeCase.content.length
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 5: Performance under memory pressure
|
||||
const memoryPressureDetection = await performanceTracker.measureAsync(
|
||||
'memory-pressure-detection',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
baseline: null,
|
||||
underPressure: null,
|
||||
degradation: null
|
||||
};
|
||||
|
||||
// Baseline measurement
|
||||
const baselineTimes = [];
|
||||
const testXml = '<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ID>MEM-TEST</ID></Invoice>';
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const start = process.hrtime.bigint();
|
||||
await einvoice.detectFormat(testXml);
|
||||
const end = process.hrtime.bigint();
|
||||
baselineTimes.push(Number(end - start) / 1_000_000);
|
||||
}
|
||||
|
||||
results.baseline = baselineTimes.reduce((a, b) => a + b, 0) / baselineTimes.length;
|
||||
|
||||
// Create memory pressure by allocating large arrays
|
||||
const memoryHogs = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
memoryHogs.push(new Array(1_000_000).fill(Math.random()));
|
||||
}
|
||||
|
||||
// Measurement under pressure
|
||||
const pressureTimes = [];
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const start = process.hrtime.bigint();
|
||||
await einvoice.detectFormat(testXml);
|
||||
const end = process.hrtime.bigint();
|
||||
pressureTimes.push(Number(end - start) / 1_000_000);
|
||||
}
|
||||
|
||||
results.underPressure = pressureTimes.reduce((a, b) => a + b, 0) / pressureTimes.length;
|
||||
results.degradation = ((results.underPressure - results.baseline) / results.baseline * 100).toFixed(2) + '%';
|
||||
|
||||
// Cleanup
|
||||
memoryHogs.length = 0;
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Summary
|
||||
t.comment('\n=== PERF-01: Format Detection Speed Test Summary ===');
|
||||
|
||||
t.comment('\nSingle File Detection Benchmarks (100 iterations each):');
|
||||
singleFileDetection.result.forEach(bench => {
|
||||
t.comment(` ${bench.name}:`);
|
||||
t.comment(` - Min: ${bench.min.toFixed(3)}ms, Max: ${bench.max.toFixed(3)}ms`);
|
||||
t.comment(` - Avg: ${bench.avg.toFixed(3)}ms, Median: ${bench.median.toFixed(3)}ms`);
|
||||
t.comment(` - P95: ${bench.p95.toFixed(3)}ms, P99: ${bench.p99.toFixed(3)}ms`);
|
||||
});
|
||||
|
||||
t.comment(`\nCorpus Detection Performance (${corpusDetection.result.totalFiles} files):`);
|
||||
t.comment(` Overall statistics:`);
|
||||
t.comment(` - Min: ${corpusDetection.result.overallStats.min.toFixed(3)}ms`);
|
||||
t.comment(` - Max: ${corpusDetection.result.overallStats.max.toFixed(3)}ms`);
|
||||
t.comment(` - Avg: ${corpusDetection.result.overallStats.avg.toFixed(3)}ms`);
|
||||
t.comment(` - Median: ${corpusDetection.result.overallStats.median.toFixed(3)}ms`);
|
||||
t.comment(` - P95: ${corpusDetection.result.overallStats.p95.toFixed(3)}ms`);
|
||||
t.comment(` By file size:`);
|
||||
Object.entries(corpusDetection.result.sizeCategories).forEach(([size, data]: [string, any]) => {
|
||||
if (data.count > 0) {
|
||||
t.comment(` - ${size}: ${data.count} files, avg ${data.avgTime.toFixed(3)}ms`);
|
||||
}
|
||||
});
|
||||
t.comment(` Format distribution:`);
|
||||
corpusDetection.result.formatDistribution.forEach(([format, count]) => {
|
||||
t.comment(` - ${format}: ${count} files`);
|
||||
});
|
||||
|
||||
t.comment('\nConcurrent Detection Performance:');
|
||||
concurrentDetection.result.forEach(result => {
|
||||
t.comment(` ${result.concurrency} concurrent: ${result.duration}ms total, ${result.throughput}`);
|
||||
});
|
||||
|
||||
t.comment('\nEdge Case Detection:');
|
||||
edgeCaseDetection.result.forEach(result => {
|
||||
t.comment(` ${result.name} (${result.contentSize} bytes): ${result.avgTime}ms avg`);
|
||||
});
|
||||
|
||||
t.comment('\nMemory Pressure Impact:');
|
||||
t.comment(` Baseline: ${memoryPressureDetection.result.baseline.toFixed(3)}ms`);
|
||||
t.comment(` Under pressure: ${memoryPressureDetection.result.underPressure.toFixed(3)}ms`);
|
||||
t.comment(` Performance degradation: ${memoryPressureDetection.result.degradation}`);
|
||||
|
||||
// Performance targets check
|
||||
t.comment('\n=== Performance Targets Check ===');
|
||||
const avgDetectionTime = corpusDetection.result.overallStats.avg;
|
||||
const targetTime = 10; // Target: <10ms for format detection
|
||||
|
||||
if (avgDetectionTime < targetTime) {
|
||||
t.comment(`✅ Format detection meets target: ${avgDetectionTime.toFixed(3)}ms < ${targetTime}ms`);
|
||||
} else {
|
||||
t.comment(`⚠️ Format detection exceeds target: ${avgDetectionTime.toFixed(3)}ms > ${targetTime}ms`);
|
||||
}
|
||||
|
||||
// Overall performance summary
|
||||
t.comment('\n=== Overall Performance Summary ===');
|
||||
performanceTracker.logSummary();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.start();
|
@ -0,0 +1,518 @@
|
||||
/**
|
||||
* @file test.perf-02.validation-performance.ts
|
||||
* @description Performance tests for invoice validation operations
|
||||
*/
|
||||
|
||||
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-02: Validation Performance');
|
||||
|
||||
tap.test('PERF-02: Validation Performance - should meet performance targets for validation operations', async (t) => {
|
||||
// Test 1: Syntax validation performance
|
||||
const syntaxValidation = await performanceTracker.measureAsync(
|
||||
'syntax-validation-performance',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = [];
|
||||
|
||||
// Create test invoices of varying complexity
|
||||
const testInvoices = [
|
||||
{
|
||||
name: 'Minimal Invoice',
|
||||
invoice: {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'PERF-VAL-001',
|
||||
issueDate: '2024-02-01',
|
||||
seller: { name: 'Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: '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 }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Standard Invoice (10 items)',
|
||||
invoice: {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'PERF-VAL-002',
|
||||
issueDate: '2024-02-01',
|
||||
dueDate: '2024-03-01',
|
||||
currency: 'EUR',
|
||||
seller: {
|
||||
name: 'Complex Seller GmbH',
|
||||
address: 'Hauptstraße 123',
|
||||
city: 'Berlin',
|
||||
postalCode: '10115',
|
||||
country: 'DE',
|
||||
taxId: 'DE123456789',
|
||||
email: 'info@seller.de',
|
||||
phone: '+49 30 12345678'
|
||||
},
|
||||
buyer: {
|
||||
name: 'Complex Buyer Ltd',
|
||||
address: 'Business Park 456',
|
||||
city: 'Munich',
|
||||
postalCode: '80331',
|
||||
country: 'DE',
|
||||
taxId: 'DE987654321',
|
||||
email: 'ap@buyer.de'
|
||||
},
|
||||
items: Array.from({ length: 10 }, (_, i) => ({
|
||||
description: `Product Line ${i + 1}`,
|
||||
quantity: i + 1,
|
||||
unitPrice: 50.00 + i * 10,
|
||||
vatRate: 19,
|
||||
lineTotal: (i + 1) * (50.00 + i * 10),
|
||||
itemId: `ITEM-${i + 1}`
|
||||
})),
|
||||
totals: {
|
||||
netAmount: 1650.00,
|
||||
vatAmount: 313.50,
|
||||
grossAmount: 1963.50
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Complex Invoice (50 items)',
|
||||
invoice: {
|
||||
format: 'cii' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'PERF-VAL-003',
|
||||
issueDate: '2024-02-01',
|
||||
seller: { name: 'Mega Seller', address: 'Complex Street', country: 'FR', taxId: 'FR12345678901' },
|
||||
buyer: { name: 'Mega Buyer', address: 'Complex Avenue', country: 'FR', taxId: 'FR98765432109' },
|
||||
items: Array.from({ length: 50 }, (_, i) => ({
|
||||
description: `Complex Item ${i + 1} with detailed specifications`,
|
||||
quantity: Math.floor(Math.random() * 10) + 1,
|
||||
unitPrice: Math.random() * 500,
|
||||
vatRate: [5.5, 10, 20][i % 3],
|
||||
lineTotal: 0 // Will be calculated
|
||||
})),
|
||||
totals: { netAmount: 0, vatAmount: 0, grossAmount: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Calculate totals for complex invoice
|
||||
testInvoices[2].invoice.data.items.forEach(item => {
|
||||
item.lineTotal = item.quantity * item.unitPrice;
|
||||
testInvoices[2].invoice.data.totals.netAmount += item.lineTotal;
|
||||
testInvoices[2].invoice.data.totals.vatAmount += item.lineTotal * (item.vatRate / 100);
|
||||
});
|
||||
testInvoices[2].invoice.data.totals.grossAmount =
|
||||
testInvoices[2].invoice.data.totals.netAmount + testInvoices[2].invoice.data.totals.vatAmount;
|
||||
|
||||
// Run validation benchmarks
|
||||
for (const test of testInvoices) {
|
||||
const times = [];
|
||||
const iterations = 50;
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const startTime = process.hrtime.bigint();
|
||||
const validationResult = await einvoice.validateInvoice(test.invoice, { level: 'syntax' });
|
||||
const endTime = process.hrtime.bigint();
|
||||
|
||||
const duration = Number(endTime - startTime) / 1_000_000;
|
||||
times.push(duration);
|
||||
}
|
||||
|
||||
times.sort((a, b) => a - b);
|
||||
|
||||
results.push({
|
||||
name: test.name,
|
||||
itemCount: test.invoice.data.items.length,
|
||||
min: times[0],
|
||||
max: times[times.length - 1],
|
||||
avg: times.reduce((a, b) => a + b, 0) / times.length,
|
||||
median: times[Math.floor(times.length / 2)],
|
||||
p95: times[Math.floor(times.length * 0.95)]
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 2: Business rule validation performance
|
||||
const businessRuleValidation = await performanceTracker.measureAsync(
|
||||
'business-rule-validation',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
ruleCategories: [],
|
||||
totalRulesChecked: 0,
|
||||
avgTimePerRule: 0
|
||||
};
|
||||
|
||||
// Create test invoice with various business rule scenarios
|
||||
const testInvoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'BR-TEST-001',
|
||||
issueDate: '2024-02-01',
|
||||
dueDate: '2024-03-01',
|
||||
currency: 'EUR',
|
||||
seller: {
|
||||
name: 'Business Rule Test Seller',
|
||||
address: 'Test Street 1',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
taxId: 'DE123456789',
|
||||
registrationNumber: 'HRB12345'
|
||||
},
|
||||
buyer: {
|
||||
name: 'Business Rule Test Buyer',
|
||||
address: 'Test Avenue 2',
|
||||
city: 'Paris',
|
||||
country: 'FR',
|
||||
taxId: 'FR98765432109'
|
||||
},
|
||||
items: [
|
||||
{
|
||||
description: 'Standard Product',
|
||||
quantity: 10,
|
||||
unitPrice: 100.00,
|
||||
vatRate: 19,
|
||||
lineTotal: 1000.00
|
||||
},
|
||||
{
|
||||
description: 'Reduced VAT Product',
|
||||
quantity: 5,
|
||||
unitPrice: 50.00,
|
||||
vatRate: 7,
|
||||
lineTotal: 250.00
|
||||
},
|
||||
{
|
||||
description: 'Zero VAT Export',
|
||||
quantity: 2,
|
||||
unitPrice: 200.00,
|
||||
vatRate: 0,
|
||||
lineTotal: 400.00
|
||||
}
|
||||
],
|
||||
totals: {
|
||||
netAmount: 1650.00,
|
||||
vatAmount: 207.50,
|
||||
grossAmount: 1857.50
|
||||
},
|
||||
paymentTerms: 'Net 30 days',
|
||||
paymentMeans: {
|
||||
iban: 'DE89370400440532013000',
|
||||
bic: 'COBADEFFXXX'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Test different validation rule sets
|
||||
const ruleSets = [
|
||||
{ name: 'BR-CO (Calculations)', rules: ['BR-CO-*'] },
|
||||
{ name: 'BR-CL (Codelists)', rules: ['BR-CL-*'] },
|
||||
{ name: 'BR-S (VAT)', rules: ['BR-S-*'] },
|
||||
{ name: 'BR-DE (Germany)', rules: ['BR-DE-*'] },
|
||||
{ name: 'All Rules', rules: ['*'] }
|
||||
];
|
||||
|
||||
for (const ruleSet of ruleSets) {
|
||||
const times = [];
|
||||
const iterations = 20;
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const startTime = process.hrtime.bigint();
|
||||
const validationResult = await einvoice.validateInvoice(testInvoice, {
|
||||
level: 'business',
|
||||
rules: ruleSet.rules
|
||||
});
|
||||
const endTime = process.hrtime.bigint();
|
||||
|
||||
const duration = Number(endTime - startTime) / 1_000_000;
|
||||
times.push(duration);
|
||||
|
||||
if (i === 0) {
|
||||
results.totalRulesChecked += validationResult.rulesChecked || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
|
||||
results.ruleCategories.push({
|
||||
name: ruleSet.name,
|
||||
avgTime: avgTime.toFixed(3),
|
||||
rulesPerMs: ((validationResult.rulesChecked || 1) / avgTime).toFixed(2)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 3: Corpus validation performance
|
||||
const corpusValidation = await performanceTracker.measureAsync(
|
||||
'corpus-validation-performance',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
totalFiles: 0,
|
||||
validationTimes: {
|
||||
syntax: [],
|
||||
semantic: [],
|
||||
business: []
|
||||
},
|
||||
formatPerformance: new Map<string, { count: number; totalTime: number }>(),
|
||||
errors: 0
|
||||
};
|
||||
|
||||
// Sample corpus files
|
||||
const sampleFiles = files.slice(0, 50);
|
||||
|
||||
for (const file of sampleFiles) {
|
||||
try {
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
|
||||
// Detect format
|
||||
const format = await einvoice.detectFormat(content);
|
||||
if (!format || format === 'unknown') continue;
|
||||
|
||||
// Parse invoice
|
||||
const invoice = await einvoice.parseInvoice(content, format);
|
||||
results.totalFiles++;
|
||||
|
||||
// Initialize format stats
|
||||
if (!results.formatPerformance.has(format)) {
|
||||
results.formatPerformance.set(format, { count: 0, totalTime: 0 });
|
||||
}
|
||||
|
||||
// Measure validation at different levels
|
||||
const levels = ['syntax', 'semantic', 'business'] as const;
|
||||
|
||||
for (const level of levels) {
|
||||
const startTime = process.hrtime.bigint();
|
||||
await einvoice.validateInvoice(invoice, { level });
|
||||
const endTime = process.hrtime.bigint();
|
||||
|
||||
const duration = Number(endTime - startTime) / 1_000_000;
|
||||
results.validationTimes[level].push(duration);
|
||||
|
||||
if (level === 'business') {
|
||||
const formatStats = results.formatPerformance.get(format)!;
|
||||
formatStats.count++;
|
||||
formatStats.totalTime += duration;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
results.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const stats = {};
|
||||
for (const level of Object.keys(results.validationTimes)) {
|
||||
const times = results.validationTimes[level];
|
||||
if (times.length > 0) {
|
||||
times.sort((a, b) => a - b);
|
||||
stats[level] = {
|
||||
min: times[0],
|
||||
max: times[times.length - 1],
|
||||
avg: times.reduce((a, b) => a + b, 0) / times.length,
|
||||
median: times[Math.floor(times.length / 2)],
|
||||
p95: times[Math.floor(times.length * 0.95)]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...results,
|
||||
stats,
|
||||
formatPerformance: Array.from(results.formatPerformance.entries()).map(([format, data]) => ({
|
||||
format,
|
||||
avgTime: data.count > 0 ? (data.totalTime / data.count).toFixed(3) : 'N/A'
|
||||
}))
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Test 4: Incremental validation performance
|
||||
const incrementalValidation = await performanceTracker.measureAsync(
|
||||
'incremental-validation',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = [];
|
||||
|
||||
// Base invoice
|
||||
const baseInvoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'INCR-001',
|
||||
issueDate: '2024-02-01',
|
||||
seller: { name: 'Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: 'Buyer', address: 'Address', country: 'US', taxId: 'US456' },
|
||||
items: [],
|
||||
totals: { netAmount: 0, vatAmount: 0, grossAmount: 0 }
|
||||
}
|
||||
};
|
||||
|
||||
// Measure validation time as we add items
|
||||
const itemCounts = [1, 5, 10, 20, 50, 100];
|
||||
|
||||
for (const count of itemCounts) {
|
||||
// Add items incrementally
|
||||
while (baseInvoice.data.items.length < count) {
|
||||
const item = {
|
||||
description: `Item ${baseInvoice.data.items.length + 1}`,
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 19,
|
||||
lineTotal: 100
|
||||
};
|
||||
baseInvoice.data.items.push(item);
|
||||
baseInvoice.data.totals.netAmount += 100;
|
||||
baseInvoice.data.totals.vatAmount += 19;
|
||||
baseInvoice.data.totals.grossAmount += 119;
|
||||
}
|
||||
|
||||
// Measure validation time
|
||||
const times = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const startTime = process.hrtime.bigint();
|
||||
await einvoice.validateInvoice(baseInvoice);
|
||||
const endTime = process.hrtime.bigint();
|
||||
times.push(Number(endTime - startTime) / 1_000_000);
|
||||
}
|
||||
|
||||
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
|
||||
results.push({
|
||||
itemCount: count,
|
||||
avgValidationTime: avgTime.toFixed(3),
|
||||
timePerItem: (avgTime / count).toFixed(4)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 5: Parallel validation performance
|
||||
const parallelValidation = await performanceTracker.measureAsync(
|
||||
'parallel-validation-performance',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = [];
|
||||
|
||||
// Create test invoice
|
||||
const testInvoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'PARALLEL-001',
|
||||
issueDate: '2024-02-01',
|
||||
seller: { name: 'Parallel Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: 'Parallel Buyer', address: 'Address', country: 'US', taxId: 'US456' },
|
||||
items: Array.from({ length: 20 }, (_, i) => ({
|
||||
description: `Item ${i + 1}`,
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 10,
|
||||
lineTotal: 100
|
||||
})),
|
||||
totals: { netAmount: 2000, vatAmount: 200, grossAmount: 2200 }
|
||||
}
|
||||
};
|
||||
|
||||
// Test different concurrency levels
|
||||
const concurrencyLevels = [1, 2, 5, 10, 20];
|
||||
|
||||
for (const concurrency of concurrencyLevels) {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Create parallel validation tasks
|
||||
const tasks = Array(concurrency).fill(null).map(() =>
|
||||
einvoice.validateInvoice(testInvoice)
|
||||
);
|
||||
|
||||
const results = await Promise.all(tasks);
|
||||
const endTime = Date.now();
|
||||
|
||||
const duration = endTime - startTime;
|
||||
const throughput = (concurrency / (duration / 1000)).toFixed(2);
|
||||
|
||||
results.push({
|
||||
concurrency,
|
||||
duration,
|
||||
throughput: `${throughput} validations/sec`,
|
||||
allValid: results.every(r => r.isValid)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Summary
|
||||
t.comment('\n=== PERF-02: Validation Performance Test Summary ===');
|
||||
|
||||
t.comment('\nSyntax Validation Performance:');
|
||||
syntaxValidation.result.forEach(result => {
|
||||
t.comment(` ${result.name} (${result.itemCount} items):`);
|
||||
t.comment(` - Min: ${result.min.toFixed(3)}ms, Max: ${result.max.toFixed(3)}ms`);
|
||||
t.comment(` - Avg: ${result.avg.toFixed(3)}ms, Median: ${result.median.toFixed(3)}ms`);
|
||||
t.comment(` - P95: ${result.p95.toFixed(3)}ms`);
|
||||
});
|
||||
|
||||
t.comment('\nBusiness Rule Validation:');
|
||||
businessRuleValidation.result.ruleCategories.forEach(category => {
|
||||
t.comment(` ${category.name}: ${category.avgTime}ms avg (${category.rulesPerMs} rules/ms)`);
|
||||
});
|
||||
|
||||
t.comment(`\nCorpus Validation (${corpusValidation.result.totalFiles} files):`);
|
||||
Object.entries(corpusValidation.result.stats).forEach(([level, stats]: [string, any]) => {
|
||||
t.comment(` ${level} validation:`);
|
||||
t.comment(` - Min: ${stats.min.toFixed(3)}ms, Max: ${stats.max.toFixed(3)}ms`);
|
||||
t.comment(` - Avg: ${stats.avg.toFixed(3)}ms, Median: ${stats.median.toFixed(3)}ms`);
|
||||
});
|
||||
t.comment(' By format:');
|
||||
corpusValidation.result.formatPerformance.forEach(perf => {
|
||||
t.comment(` - ${perf.format}: ${perf.avgTime}ms avg`);
|
||||
});
|
||||
|
||||
t.comment('\nIncremental Validation Scaling:');
|
||||
incrementalValidation.result.forEach(result => {
|
||||
t.comment(` ${result.itemCount} items: ${result.avgValidationTime}ms (${result.timePerItem}ms/item)`);
|
||||
});
|
||||
|
||||
t.comment('\nParallel Validation:');
|
||||
parallelValidation.result.forEach(result => {
|
||||
t.comment(` ${result.concurrency} concurrent: ${result.duration}ms, ${result.throughput}`);
|
||||
});
|
||||
|
||||
// Performance targets check
|
||||
t.comment('\n=== Performance Targets Check ===');
|
||||
const syntaxAvg = syntaxValidation.result[1].avg; // Standard invoice
|
||||
const businessAvg = businessRuleValidation.result.ruleCategories.find(r => r.name === 'All Rules')?.avgTime || 0;
|
||||
|
||||
t.comment(`Syntax validation: ${syntaxAvg.toFixed(3)}ms ${syntaxAvg < 50 ? '✅' : '⚠️'} (target: <50ms)`);
|
||||
t.comment(`Business validation: ${businessAvg}ms ${parseFloat(businessAvg) < 200 ? '✅' : '⚠️'} (target: <200ms)`);
|
||||
|
||||
// Overall performance summary
|
||||
t.comment('\n=== Overall Performance Summary ===');
|
||||
performanceTracker.logSummary();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.start();
|
427
test/suite/einvoice_performance/test.perf-03.pdf-extraction.ts
Normal file
427
test/suite/einvoice_performance/test.perf-03.pdf-extraction.ts
Normal file
@ -0,0 +1,427 @@
|
||||
/**
|
||||
* @file test.perf-03.pdf-extraction.ts
|
||||
* @description Performance tests for PDF extraction operations
|
||||
*/
|
||||
|
||||
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-03: PDF Extraction Speed');
|
||||
|
||||
tap.test('PERF-03: PDF Extraction Speed - should meet performance targets for PDF extraction', async (t) => {
|
||||
// Test 1: ZUGFeRD v1 extraction performance
|
||||
const zugferdV1Performance = await performanceTracker.measureAsync(
|
||||
'zugferd-v1-extraction',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/ZUGFeRDv1/**/*.pdf');
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
fileCount: 0,
|
||||
extractionTimes: [],
|
||||
fileSizes: [],
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
bytesPerMs: []
|
||||
};
|
||||
|
||||
// Process ZUGFeRD v1 PDFs
|
||||
const sampleFiles = files.slice(0, 20);
|
||||
|
||||
for (const file of sampleFiles) {
|
||||
try {
|
||||
const pdfBuffer = await plugins.fs.readFile(file);
|
||||
const fileSize = pdfBuffer.length;
|
||||
results.fileSizes.push(fileSize);
|
||||
results.fileCount++;
|
||||
|
||||
// Measure extraction time
|
||||
const startTime = process.hrtime.bigint();
|
||||
const extractedXml = await einvoice.extractFromPDF(pdfBuffer);
|
||||
const endTime = process.hrtime.bigint();
|
||||
|
||||
const duration = Number(endTime - startTime) / 1_000_000;
|
||||
results.extractionTimes.push(duration);
|
||||
|
||||
if (extractedXml) {
|
||||
results.successCount++;
|
||||
results.bytesPerMs.push(fileSize / duration);
|
||||
} else {
|
||||
results.failureCount++;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
results.failureCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
if (results.extractionTimes.length > 0) {
|
||||
results.extractionTimes.sort((a, b) => a - b);
|
||||
const stats = {
|
||||
min: results.extractionTimes[0],
|
||||
max: results.extractionTimes[results.extractionTimes.length - 1],
|
||||
avg: results.extractionTimes.reduce((a, b) => a + b, 0) / results.extractionTimes.length,
|
||||
median: results.extractionTimes[Math.floor(results.extractionTimes.length / 2)],
|
||||
avgFileSize: results.fileSizes.reduce((a, b) => a + b, 0) / results.fileSizes.length / 1024, // KB
|
||||
avgBytesPerMs: results.bytesPerMs.length > 0 ?
|
||||
results.bytesPerMs.reduce((a, b) => a + b, 0) / results.bytesPerMs.length / 1024 : 0 // KB/ms
|
||||
};
|
||||
|
||||
return { ...results, stats };
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 2: ZUGFeRD v2/Factur-X extraction performance
|
||||
const facturXPerformance = await performanceTracker.measureAsync(
|
||||
'facturx-extraction',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/ZUGFeRDv2/**/*.pdf');
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
profiles: new Map<string, { count: number; totalTime: number }>(),
|
||||
extractionTimes: [],
|
||||
xmlSizes: [],
|
||||
largestFile: { path: '', size: 0, time: 0 },
|
||||
smallestFile: { path: '', size: Infinity, time: 0 }
|
||||
};
|
||||
|
||||
// Process Factur-X PDFs
|
||||
const sampleFiles = files.slice(0, 30);
|
||||
|
||||
for (const file of sampleFiles) {
|
||||
try {
|
||||
const pdfBuffer = await plugins.fs.readFile(file);
|
||||
const fileSize = pdfBuffer.length;
|
||||
|
||||
// Measure extraction
|
||||
const startTime = process.hrtime.bigint();
|
||||
const extractedXml = await einvoice.extractFromPDF(pdfBuffer);
|
||||
const endTime = process.hrtime.bigint();
|
||||
|
||||
const duration = Number(endTime - startTime) / 1_000_000;
|
||||
results.extractionTimes.push(duration);
|
||||
|
||||
if (extractedXml) {
|
||||
const xmlSize = Buffer.byteLength(extractedXml, 'utf-8');
|
||||
results.xmlSizes.push(xmlSize);
|
||||
|
||||
// Detect profile from filename or content
|
||||
const profile = file.includes('BASIC') ? 'BASIC' :
|
||||
file.includes('COMFORT') ? 'COMFORT' :
|
||||
file.includes('EXTENDED') ? 'EXTENDED' : 'UNKNOWN';
|
||||
|
||||
if (!results.profiles.has(profile)) {
|
||||
results.profiles.set(profile, { count: 0, totalTime: 0 });
|
||||
}
|
||||
|
||||
const profileStats = results.profiles.get(profile)!;
|
||||
profileStats.count++;
|
||||
profileStats.totalTime += duration;
|
||||
|
||||
// Track largest/smallest
|
||||
if (fileSize > results.largestFile.size) {
|
||||
results.largestFile = { path: file, size: fileSize, time: duration };
|
||||
}
|
||||
if (fileSize < results.smallestFile.size) {
|
||||
results.smallestFile = { path: file, size: fileSize, time: duration };
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Skip failed extractions
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate profile statistics
|
||||
const profileStats = Array.from(results.profiles.entries()).map(([profile, data]) => ({
|
||||
profile,
|
||||
count: data.count,
|
||||
avgTime: data.count > 0 ? (data.totalTime / data.count).toFixed(3) : 'N/A'
|
||||
}));
|
||||
|
||||
return {
|
||||
totalFiles: sampleFiles.length,
|
||||
successfulExtractions: results.extractionTimes.length,
|
||||
avgExtractionTime: results.extractionTimes.length > 0 ?
|
||||
(results.extractionTimes.reduce((a, b) => a + b, 0) / results.extractionTimes.length).toFixed(3) : 'N/A',
|
||||
avgXmlSize: results.xmlSizes.length > 0 ?
|
||||
(results.xmlSizes.reduce((a, b) => a + b, 0) / results.xmlSizes.length / 1024).toFixed(2) : 'N/A',
|
||||
profileStats,
|
||||
largestFile: {
|
||||
...results.largestFile,
|
||||
sizeKB: (results.largestFile.size / 1024).toFixed(2),
|
||||
timeMs: results.largestFile.time.toFixed(3)
|
||||
},
|
||||
smallestFile: {
|
||||
...results.smallestFile,
|
||||
sizeKB: (results.smallestFile.size / 1024).toFixed(2),
|
||||
timeMs: results.smallestFile.time.toFixed(3)
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Test 3: Large PDF extraction performance
|
||||
const largePDFPerformance = await performanceTracker.measureAsync(
|
||||
'large-pdf-extraction',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = [];
|
||||
|
||||
// Create synthetic large PDFs with embedded XML
|
||||
const pdfSizes = [
|
||||
{ name: '1MB', size: 1024 * 1024, xmlSize: 50 * 1024 },
|
||||
{ name: '5MB', size: 5 * 1024 * 1024, xmlSize: 100 * 1024 },
|
||||
{ name: '10MB', size: 10 * 1024 * 1024, xmlSize: 200 * 1024 },
|
||||
{ name: '20MB', size: 20 * 1024 * 1024, xmlSize: 500 * 1024 }
|
||||
];
|
||||
|
||||
for (const pdfSpec of pdfSizes) {
|
||||
// Simulate PDF content (in real scenario, would use actual PDF library)
|
||||
const mockPdfBuffer = Buffer.alloc(pdfSpec.size);
|
||||
|
||||
// Fill with some pattern to simulate real PDF
|
||||
for (let i = 0; i < mockPdfBuffer.length; i += 1024) {
|
||||
mockPdfBuffer.write('%PDF-1.4\n', i);
|
||||
}
|
||||
|
||||
// Embed mock XML at a known location
|
||||
const mockXml = `<?xml version="1.0"?>
|
||||
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100">
|
||||
<rsm:ExchangedDocument>
|
||||
<ram:ID>LARGE-PDF-TEST</ram:ID>
|
||||
${' '.repeat(pdfSpec.xmlSize - 200)}
|
||||
</rsm:ExchangedDocument>
|
||||
</rsm:CrossIndustryInvoice>`;
|
||||
|
||||
// Measure extraction time
|
||||
const times = [];
|
||||
const iterations = 5;
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const startTime = process.hrtime.bigint();
|
||||
|
||||
try {
|
||||
// Simulate extraction (would use real PDF library)
|
||||
await new Promise(resolve => setTimeout(resolve, pdfSpec.size / (50 * 1024 * 1024))); // Simulate 50MB/s extraction
|
||||
|
||||
const endTime = process.hrtime.bigint();
|
||||
const duration = Number(endTime - startTime) / 1_000_000;
|
||||
times.push(duration);
|
||||
} catch (error) {
|
||||
// Extraction failed
|
||||
}
|
||||
}
|
||||
|
||||
if (times.length > 0) {
|
||||
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
results.push({
|
||||
size: pdfSpec.name,
|
||||
sizeBytes: pdfSpec.size,
|
||||
avgExtractionTime: avgTime.toFixed(3),
|
||||
throughputMBps: (pdfSpec.size / avgTime / 1024).toFixed(2)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 4: Concurrent PDF extraction
|
||||
const concurrentExtraction = await performanceTracker.measureAsync(
|
||||
'concurrent-pdf-extraction',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.pdf');
|
||||
const einvoice = new EInvoice();
|
||||
const results = [];
|
||||
|
||||
// Select sample PDFs
|
||||
const samplePDFs = files.slice(0, 10);
|
||||
if (samplePDFs.length === 0) {
|
||||
return { error: 'No PDF files found for testing' };
|
||||
}
|
||||
|
||||
// Test different concurrency levels
|
||||
const concurrencyLevels = [1, 2, 5, 10];
|
||||
|
||||
for (const concurrency of concurrencyLevels) {
|
||||
const startTime = Date.now();
|
||||
let successCount = 0;
|
||||
|
||||
// Create extraction tasks
|
||||
const tasks = [];
|
||||
for (let i = 0; i < concurrency; i++) {
|
||||
const pdfFile = samplePDFs[i % samplePDFs.length];
|
||||
tasks.push(
|
||||
plugins.fs.readFile(pdfFile)
|
||||
.then(buffer => einvoice.extractFromPDF(buffer))
|
||||
.then(xml => xml ? successCount++ : null)
|
||||
.catch(() => null)
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
results.push({
|
||||
concurrency,
|
||||
duration,
|
||||
successCount,
|
||||
throughput: (successCount / (duration / 1000)).toFixed(2),
|
||||
avgTimePerExtraction: (duration / concurrency).toFixed(3)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 5: Memory efficiency during extraction
|
||||
const memoryEfficiency = await performanceTracker.measureAsync(
|
||||
'extraction-memory-efficiency',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.pdf');
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
memorySnapshots: [],
|
||||
peakMemoryUsage: 0,
|
||||
avgMemoryPerExtraction: 0
|
||||
};
|
||||
|
||||
// Force garbage collection if available
|
||||
if (global.gc) global.gc();
|
||||
const baselineMemory = process.memoryUsage();
|
||||
|
||||
// Process PDFs and monitor memory
|
||||
const sampleFiles = files.slice(0, 20);
|
||||
let extractionCount = 0;
|
||||
|
||||
for (const file of sampleFiles) {
|
||||
try {
|
||||
const pdfBuffer = await plugins.fs.readFile(file);
|
||||
|
||||
// Memory before extraction
|
||||
const beforeMemory = process.memoryUsage();
|
||||
|
||||
// Extract XML
|
||||
const xml = await einvoice.extractFromPDF(pdfBuffer);
|
||||
|
||||
// Memory after extraction
|
||||
const afterMemory = process.memoryUsage();
|
||||
|
||||
if (xml) {
|
||||
extractionCount++;
|
||||
|
||||
const memoryIncrease = {
|
||||
heapUsed: (afterMemory.heapUsed - beforeMemory.heapUsed) / 1024 / 1024,
|
||||
external: (afterMemory.external - beforeMemory.external) / 1024 / 1024,
|
||||
fileSize: pdfBuffer.length / 1024 / 1024
|
||||
};
|
||||
|
||||
results.memorySnapshots.push(memoryIncrease);
|
||||
|
||||
if (afterMemory.heapUsed > results.peakMemoryUsage) {
|
||||
results.peakMemoryUsage = afterMemory.heapUsed;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Skip failed extractions
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
if (results.memorySnapshots.length > 0) {
|
||||
const totalMemoryIncrease = results.memorySnapshots
|
||||
.reduce((sum, snap) => sum + snap.heapUsed, 0);
|
||||
results.avgMemoryPerExtraction = totalMemoryIncrease / results.memorySnapshots.length;
|
||||
}
|
||||
|
||||
// Force garbage collection and measure final state
|
||||
if (global.gc) global.gc();
|
||||
const finalMemory = process.memoryUsage();
|
||||
|
||||
return {
|
||||
extractionsProcessed: extractionCount,
|
||||
peakMemoryMB: ((results.peakMemoryUsage - baselineMemory.heapUsed) / 1024 / 1024).toFixed(2),
|
||||
avgMemoryPerExtractionMB: results.avgMemoryPerExtraction.toFixed(2),
|
||||
memoryLeakDetected: (finalMemory.heapUsed - baselineMemory.heapUsed) > 50 * 1024 * 1024,
|
||||
finalMemoryIncreaseMB: ((finalMemory.heapUsed - baselineMemory.heapUsed) / 1024 / 1024).toFixed(2)
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Summary
|
||||
t.comment('\n=== PERF-03: PDF Extraction Speed Test Summary ===');
|
||||
|
||||
if (zugferdV1Performance.result.stats) {
|
||||
t.comment('\nZUGFeRD v1 Extraction Performance:');
|
||||
t.comment(` Files processed: ${zugferdV1Performance.result.fileCount}`);
|
||||
t.comment(` Success rate: ${(zugferdV1Performance.result.successCount / zugferdV1Performance.result.fileCount * 100).toFixed(1)}%`);
|
||||
t.comment(` Extraction times:`);
|
||||
t.comment(` - Min: ${zugferdV1Performance.result.stats.min.toFixed(3)}ms`);
|
||||
t.comment(` - Max: ${zugferdV1Performance.result.stats.max.toFixed(3)}ms`);
|
||||
t.comment(` - Avg: ${zugferdV1Performance.result.stats.avg.toFixed(3)}ms`);
|
||||
t.comment(` - Median: ${zugferdV1Performance.result.stats.median.toFixed(3)}ms`);
|
||||
t.comment(` Average file size: ${zugferdV1Performance.result.stats.avgFileSize.toFixed(2)}KB`);
|
||||
t.comment(` Throughput: ${zugferdV1Performance.result.stats.avgBytesPerMs.toFixed(2)}KB/ms`);
|
||||
}
|
||||
|
||||
t.comment('\nFactur-X/ZUGFeRD v2 Extraction Performance:');
|
||||
t.comment(` Files processed: ${facturXPerformance.result.totalFiles}`);
|
||||
t.comment(` Successful extractions: ${facturXPerformance.result.successfulExtractions}`);
|
||||
t.comment(` Average extraction time: ${facturXPerformance.result.avgExtractionTime}ms`);
|
||||
t.comment(` Average XML size: ${facturXPerformance.result.avgXmlSize}KB`);
|
||||
t.comment(' By profile:');
|
||||
facturXPerformance.result.profileStats.forEach(stat => {
|
||||
t.comment(` - ${stat.profile}: ${stat.count} files, avg ${stat.avgTime}ms`);
|
||||
});
|
||||
t.comment(` Largest file: ${facturXPerformance.result.largestFile.sizeKB}KB in ${facturXPerformance.result.largestFile.timeMs}ms`);
|
||||
t.comment(` Smallest file: ${facturXPerformance.result.smallestFile.sizeKB}KB in ${facturXPerformance.result.smallestFile.timeMs}ms`);
|
||||
|
||||
t.comment('\nLarge PDF Extraction Performance:');
|
||||
largePDFPerformance.result.forEach(result => {
|
||||
t.comment(` ${result.size}: ${result.avgExtractionTime}ms (${result.throughputMBps}MB/s)`);
|
||||
});
|
||||
|
||||
t.comment('\nConcurrent Extraction Performance:');
|
||||
concurrentExtraction.result.forEach(result => {
|
||||
if (!result.error) {
|
||||
t.comment(` ${result.concurrency} concurrent: ${result.duration}ms total, ${result.throughput} extractions/sec`);
|
||||
}
|
||||
});
|
||||
|
||||
t.comment('\nMemory Efficiency:');
|
||||
t.comment(` Extractions processed: ${memoryEfficiency.result.extractionsProcessed}`);
|
||||
t.comment(` Peak memory usage: ${memoryEfficiency.result.peakMemoryMB}MB`);
|
||||
t.comment(` Avg memory per extraction: ${memoryEfficiency.result.avgMemoryPerExtractionMB}MB`);
|
||||
t.comment(` Memory leak detected: ${memoryEfficiency.result.memoryLeakDetected ? 'YES ⚠️' : 'NO ✅'}`);
|
||||
t.comment(` Final memory increase: ${memoryEfficiency.result.finalMemoryIncreaseMB}MB`);
|
||||
|
||||
// Performance targets check
|
||||
t.comment('\n=== Performance Targets Check ===');
|
||||
const avgExtractionTime = parseFloat(facturXPerformance.result.avgExtractionTime) || 0;
|
||||
const targetTime = 500; // Target: <500ms for PDF extraction
|
||||
|
||||
if (avgExtractionTime > 0 && avgExtractionTime < targetTime) {
|
||||
t.comment(`✅ PDF extraction meets target: ${avgExtractionTime}ms < ${targetTime}ms`);
|
||||
} else if (avgExtractionTime > 0) {
|
||||
t.comment(`⚠️ PDF extraction exceeds target: ${avgExtractionTime}ms > ${targetTime}ms`);
|
||||
}
|
||||
|
||||
// Overall performance summary
|
||||
t.comment('\n=== Overall Performance Summary ===');
|
||||
performanceTracker.logSummary();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.start();
|
@ -0,0 +1,583 @@
|
||||
/**
|
||||
* @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();
|
569
test/suite/einvoice_performance/test.perf-05.memory-usage.ts
Normal file
569
test/suite/einvoice_performance/test.perf-05.memory-usage.ts
Normal file
@ -0,0 +1,569 @@
|
||||
/**
|
||||
* @file test.perf-05.memory-usage.ts
|
||||
* @description Performance tests for memory usage profiling
|
||||
*/
|
||||
|
||||
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-05: Memory Usage Profiling');
|
||||
|
||||
tap.test('PERF-05: Memory Usage Profiling - should maintain efficient memory usage patterns', async (t) => {
|
||||
// Test 1: Baseline memory usage for different operations
|
||||
const baselineMemoryUsage = await performanceTracker.measureAsync(
|
||||
'baseline-memory-usage',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
operations: [],
|
||||
initialMemory: null,
|
||||
finalMemory: null
|
||||
};
|
||||
|
||||
// Force garbage collection if available
|
||||
if (global.gc) global.gc();
|
||||
results.initialMemory = process.memoryUsage();
|
||||
|
||||
// Test different operations
|
||||
const operations = [
|
||||
{
|
||||
name: 'Format Detection',
|
||||
fn: async () => {
|
||||
const xml = '<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ID>TEST</ID></Invoice>';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await einvoice.detectFormat(xml);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'XML Parsing',
|
||||
fn: async () => {
|
||||
const xml = `<?xml version="1.0"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>MEM-TEST</ID>
|
||||
<IssueDate>2024-01-01</IssueDate>
|
||||
${Array(10).fill('<InvoiceLine><ID>Line</ID></InvoiceLine>').join('\n')}
|
||||
</Invoice>`;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await einvoice.parseInvoice(xml, 'ubl');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Validation',
|
||||
fn: async () => {
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'MEM-VAL-001',
|
||||
issueDate: '2024-02-10',
|
||||
seller: { name: 'Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: 'Buyer', address: 'Address', country: 'US', taxId: 'US456' },
|
||||
items: Array.from({ length: 20 }, (_, i) => ({
|
||||
description: `Item ${i + 1}`,
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 10,
|
||||
lineTotal: 100
|
||||
})),
|
||||
totals: { netAmount: 2000, vatAmount: 200, grossAmount: 2200 }
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await einvoice.validateInvoice(invoice);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Format Conversion',
|
||||
fn: async () => {
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'MEM-CONV-001',
|
||||
issueDate: '2024-02-10',
|
||||
seller: { name: 'Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: '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 }
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await einvoice.convertFormat(invoice, 'cii');
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Execute operations and measure memory
|
||||
for (const operation of operations) {
|
||||
if (global.gc) global.gc();
|
||||
const beforeMemory = process.memoryUsage();
|
||||
|
||||
await operation.fn();
|
||||
|
||||
if (global.gc) global.gc();
|
||||
const afterMemory = process.memoryUsage();
|
||||
|
||||
results.operations.push({
|
||||
name: operation.name,
|
||||
heapUsedBefore: (beforeMemory.heapUsed / 1024 / 1024).toFixed(2),
|
||||
heapUsedAfter: (afterMemory.heapUsed / 1024 / 1024).toFixed(2),
|
||||
heapIncrease: ((afterMemory.heapUsed - beforeMemory.heapUsed) / 1024 / 1024).toFixed(2),
|
||||
externalIncrease: ((afterMemory.external - beforeMemory.external) / 1024 / 1024).toFixed(2),
|
||||
rssIncrease: ((afterMemory.rss - beforeMemory.rss) / 1024 / 1024).toFixed(2)
|
||||
});
|
||||
}
|
||||
|
||||
if (global.gc) global.gc();
|
||||
results.finalMemory = process.memoryUsage();
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 2: Memory scaling with invoice complexity
|
||||
const memoryScaling = await performanceTracker.measureAsync(
|
||||
'memory-scaling',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
scalingData: [],
|
||||
memoryFormula: null
|
||||
};
|
||||
|
||||
// Test with increasing invoice sizes
|
||||
const itemCounts = [1, 10, 50, 100, 200, 500, 1000];
|
||||
|
||||
for (const itemCount of itemCounts) {
|
||||
if (global.gc) global.gc();
|
||||
const beforeMemory = process.memoryUsage();
|
||||
|
||||
// Create invoice with specified number of items
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `SCALE-${itemCount}`,
|
||||
issueDate: '2024-02-10',
|
||||
seller: {
|
||||
name: 'Memory Test Seller Corporation Ltd.',
|
||||
address: '123 Memory Lane, Suite 456',
|
||||
city: 'Test City',
|
||||
postalCode: '12345',
|
||||
country: 'US',
|
||||
taxId: 'US123456789'
|
||||
},
|
||||
buyer: {
|
||||
name: 'Memory Test Buyer Enterprises Inc.',
|
||||
address: '789 RAM Avenue, Floor 10',
|
||||
city: 'Cache Town',
|
||||
postalCode: '67890',
|
||||
country: 'US',
|
||||
taxId: 'US987654321'
|
||||
},
|
||||
items: Array.from({ length: itemCount }, (_, i) => ({
|
||||
description: `Product Item Number ${i + 1} with detailed description and specifications`,
|
||||
quantity: Math.floor(Math.random() * 100) + 1,
|
||||
unitPrice: Math.random() * 1000,
|
||||
vatRate: [5, 10, 15, 20][Math.floor(Math.random() * 4)],
|
||||
lineTotal: 0,
|
||||
itemId: `ITEM-${String(i + 1).padStart(6, '0')}`,
|
||||
additionalInfo: {
|
||||
weight: `${Math.random() * 10}kg`,
|
||||
dimensions: `${Math.random() * 100}x${Math.random() * 100}x${Math.random() * 100}`,
|
||||
notes: `Additional notes for item ${i + 1}`
|
||||
}
|
||||
})),
|
||||
totals: { netAmount: 0, vatAmount: 0, grossAmount: 0 }
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate totals
|
||||
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 invoice through multiple operations
|
||||
const parsed = await einvoice.parseInvoice(JSON.stringify(invoice), 'json');
|
||||
await einvoice.validateInvoice(parsed);
|
||||
await einvoice.convertFormat(parsed, 'cii');
|
||||
|
||||
if (global.gc) global.gc();
|
||||
const afterMemory = process.memoryUsage();
|
||||
|
||||
const memoryUsed = (afterMemory.heapUsed - beforeMemory.heapUsed) / 1024 / 1024;
|
||||
const invoiceSize = JSON.stringify(invoice).length / 1024; // KB
|
||||
|
||||
results.scalingData.push({
|
||||
itemCount,
|
||||
invoiceSizeKB: invoiceSize.toFixed(2),
|
||||
memoryUsedMB: memoryUsed.toFixed(2),
|
||||
memoryPerItemKB: ((memoryUsed * 1024) / itemCount).toFixed(2),
|
||||
memoryEfficiency: (invoiceSize / (memoryUsed * 1024)).toFixed(3)
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate memory scaling formula (linear regression)
|
||||
if (results.scalingData.length > 2) {
|
||||
const n = results.scalingData.length;
|
||||
const sumX = results.scalingData.reduce((sum, d) => sum + d.itemCount, 0);
|
||||
const sumY = results.scalingData.reduce((sum, d) => sum + parseFloat(d.memoryUsedMB), 0);
|
||||
const sumXY = results.scalingData.reduce((sum, d) => sum + d.itemCount * parseFloat(d.memoryUsedMB), 0);
|
||||
const sumX2 = results.scalingData.reduce((sum, d) => sum + d.itemCount * d.itemCount, 0);
|
||||
|
||||
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
|
||||
const intercept = (sumY - slope * sumX) / n;
|
||||
|
||||
results.memoryFormula = {
|
||||
slope: slope.toFixed(4),
|
||||
intercept: intercept.toFixed(4),
|
||||
formula: `Memory(MB) = ${slope.toFixed(4)} * items + ${intercept.toFixed(4)}`
|
||||
};
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 3: Memory leak detection
|
||||
const memoryLeakDetection = await performanceTracker.measureAsync(
|
||||
'memory-leak-detection',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
iterations: 100,
|
||||
memorySnapshots: [],
|
||||
leakDetected: false,
|
||||
leakRate: 0
|
||||
};
|
||||
|
||||
// Test invoice for repeated operations
|
||||
const testInvoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'LEAK-TEST-001',
|
||||
issueDate: '2024-02-10',
|
||||
seller: { name: 'Leak Test Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: 'Leak Test Buyer', address: 'Address', country: 'US', taxId: 'US456' },
|
||||
items: Array.from({ length: 10 }, (_, i) => ({
|
||||
description: `Item ${i + 1}`,
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 10,
|
||||
lineTotal: 100
|
||||
})),
|
||||
totals: { netAmount: 1000, vatAmount: 100, grossAmount: 1100 }
|
||||
}
|
||||
};
|
||||
|
||||
// Take memory snapshots during repeated operations
|
||||
for (let i = 0; i < results.iterations; i++) {
|
||||
if (i % 10 === 0) {
|
||||
if (global.gc) global.gc();
|
||||
const memory = process.memoryUsage();
|
||||
results.memorySnapshots.push({
|
||||
iteration: i,
|
||||
heapUsedMB: memory.heapUsed / 1024 / 1024
|
||||
});
|
||||
}
|
||||
|
||||
// Perform operations that might leak memory
|
||||
const xml = await einvoice.generateXML(testInvoice);
|
||||
const parsed = await einvoice.parseInvoice(xml, 'ubl');
|
||||
await einvoice.validateInvoice(parsed);
|
||||
await einvoice.convertFormat(parsed, 'cii');
|
||||
}
|
||||
|
||||
// Final snapshot
|
||||
if (global.gc) global.gc();
|
||||
const finalMemory = process.memoryUsage();
|
||||
results.memorySnapshots.push({
|
||||
iteration: results.iterations,
|
||||
heapUsedMB: finalMemory.heapUsed / 1024 / 1024
|
||||
});
|
||||
|
||||
// Analyze for memory leaks
|
||||
if (results.memorySnapshots.length > 2) {
|
||||
const firstSnapshot = results.memorySnapshots[0];
|
||||
const lastSnapshot = results.memorySnapshots[results.memorySnapshots.length - 1];
|
||||
const memoryIncrease = lastSnapshot.heapUsedMB - firstSnapshot.heapUsedMB;
|
||||
|
||||
results.leakRate = memoryIncrease / results.iterations; // MB per iteration
|
||||
results.leakDetected = results.leakRate > 0.1; // Threshold: 0.1MB per iteration
|
||||
|
||||
// Calculate trend
|
||||
const midpoint = Math.floor(results.memorySnapshots.length / 2);
|
||||
const firstHalf = results.memorySnapshots.slice(0, midpoint);
|
||||
const secondHalf = results.memorySnapshots.slice(midpoint);
|
||||
|
||||
const firstHalfAvg = firstHalf.reduce((sum, s) => sum + s.heapUsedMB, 0) / firstHalf.length;
|
||||
const secondHalfAvg = secondHalf.reduce((sum, s) => sum + s.heapUsedMB, 0) / secondHalf.length;
|
||||
|
||||
results.trend = {
|
||||
firstHalfAvgMB: firstHalfAvg.toFixed(2),
|
||||
secondHalfAvgMB: secondHalfAvg.toFixed(2),
|
||||
increasing: secondHalfAvg > firstHalfAvg * 1.1
|
||||
};
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 4: Corpus processing memory profile
|
||||
const corpusMemoryProfile = await performanceTracker.measureAsync(
|
||||
'corpus-memory-profile',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
filesProcessed: 0,
|
||||
memoryByFormat: new Map<string, { count: number; totalMemory: number }>(),
|
||||
memoryBySize: {
|
||||
small: { count: 0, avgMemory: 0, total: 0 },
|
||||
medium: { count: 0, avgMemory: 0, total: 0 },
|
||||
large: { count: 0, avgMemory: 0, total: 0 }
|
||||
},
|
||||
peakMemory: 0,
|
||||
totalAllocated: 0
|
||||
};
|
||||
|
||||
// Initial memory state
|
||||
if (global.gc) global.gc();
|
||||
const startMemory = process.memoryUsage();
|
||||
|
||||
// Process sample files
|
||||
const sampleFiles = files.slice(0, 30);
|
||||
|
||||
for (const file of sampleFiles) {
|
||||
try {
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
const fileSize = Buffer.byteLength(content, 'utf-8');
|
||||
const sizeCategory = fileSize < 10240 ? 'small' :
|
||||
fileSize < 102400 ? 'medium' : 'large';
|
||||
|
||||
const beforeProcess = process.memoryUsage();
|
||||
|
||||
// Process file
|
||||
const format = await einvoice.detectFormat(content);
|
||||
if (!format || format === 'unknown') continue;
|
||||
|
||||
const invoice = await einvoice.parseInvoice(content, format);
|
||||
await einvoice.validateInvoice(invoice);
|
||||
|
||||
const afterProcess = process.memoryUsage();
|
||||
const memoryUsed = (afterProcess.heapUsed - beforeProcess.heapUsed) / 1024 / 1024;
|
||||
|
||||
// Update statistics
|
||||
results.filesProcessed++;
|
||||
results.totalAllocated += memoryUsed;
|
||||
|
||||
// By format
|
||||
if (!results.memoryByFormat.has(format)) {
|
||||
results.memoryByFormat.set(format, { count: 0, totalMemory: 0 });
|
||||
}
|
||||
const formatStats = results.memoryByFormat.get(format)!;
|
||||
formatStats.count++;
|
||||
formatStats.totalMemory += memoryUsed;
|
||||
|
||||
// By size
|
||||
results.memoryBySize[sizeCategory].count++;
|
||||
results.memoryBySize[sizeCategory].total += memoryUsed;
|
||||
|
||||
// Track peak
|
||||
if (afterProcess.heapUsed > results.peakMemory) {
|
||||
results.peakMemory = afterProcess.heapUsed;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Skip failed files
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
for (const category of Object.keys(results.memoryBySize)) {
|
||||
const stats = results.memoryBySize[category];
|
||||
if (stats.count > 0) {
|
||||
stats.avgMemory = stats.total / stats.count;
|
||||
}
|
||||
}
|
||||
|
||||
// Format statistics
|
||||
const formatStats = Array.from(results.memoryByFormat.entries()).map(([format, stats]) => ({
|
||||
format,
|
||||
count: stats.count,
|
||||
avgMemoryMB: (stats.totalMemory / stats.count).toFixed(2)
|
||||
}));
|
||||
|
||||
return {
|
||||
filesProcessed: results.filesProcessed,
|
||||
totalAllocatedMB: results.totalAllocated.toFixed(2),
|
||||
peakMemoryMB: ((results.peakMemory - startMemory.heapUsed) / 1024 / 1024).toFixed(2),
|
||||
avgMemoryPerFileMB: (results.totalAllocated / results.filesProcessed).toFixed(2),
|
||||
formatStats,
|
||||
sizeStats: {
|
||||
small: { ...results.memoryBySize.small, avgMemory: results.memoryBySize.small.avgMemory.toFixed(2) },
|
||||
medium: { ...results.memoryBySize.medium, avgMemory: results.memoryBySize.medium.avgMemory.toFixed(2) },
|
||||
large: { ...results.memoryBySize.large, avgMemory: results.memoryBySize.large.avgMemory.toFixed(2) }
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Test 5: Garbage collection impact
|
||||
const gcImpact = await performanceTracker.measureAsync(
|
||||
'gc-impact',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
withManualGC: { times: [], avgTime: 0 },
|
||||
withoutGC: { times: [], avgTime: 0 },
|
||||
gcOverhead: 0
|
||||
};
|
||||
|
||||
// Test invoice
|
||||
const testInvoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'GC-TEST-001',
|
||||
issueDate: '2024-02-10',
|
||||
seller: { name: 'GC Test Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: 'GC Test Buyer', address: 'Address', country: 'US', taxId: 'US456' },
|
||||
items: Array.from({ length: 50 }, (_, i) => ({
|
||||
description: `Item ${i + 1}`,
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 10,
|
||||
lineTotal: 100
|
||||
})),
|
||||
totals: { netAmount: 5000, vatAmount: 500, grossAmount: 5500 }
|
||||
}
|
||||
};
|
||||
|
||||
// Test with manual GC
|
||||
if (global.gc) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
global.gc();
|
||||
const start = process.hrtime.bigint();
|
||||
|
||||
await einvoice.parseInvoice(JSON.stringify(testInvoice), 'json');
|
||||
await einvoice.validateInvoice(testInvoice);
|
||||
await einvoice.convertFormat(testInvoice, 'cii');
|
||||
|
||||
const end = process.hrtime.bigint();
|
||||
results.withManualGC.times.push(Number(end - start) / 1_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
// Test without manual GC
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const start = process.hrtime.bigint();
|
||||
|
||||
await einvoice.parseInvoice(JSON.stringify(testInvoice), 'json');
|
||||
await einvoice.validateInvoice(testInvoice);
|
||||
await einvoice.convertFormat(testInvoice, 'cii');
|
||||
|
||||
const end = process.hrtime.bigint();
|
||||
results.withoutGC.times.push(Number(end - start) / 1_000_000);
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
if (results.withManualGC.times.length > 0) {
|
||||
results.withManualGC.avgTime = results.withManualGC.times.reduce((a, b) => a + b, 0) / results.withManualGC.times.length;
|
||||
}
|
||||
results.withoutGC.avgTime = results.withoutGC.times.reduce((a, b) => a + b, 0) / results.withoutGC.times.length;
|
||||
|
||||
if (results.withManualGC.avgTime > 0) {
|
||||
results.gcOverhead = ((results.withManualGC.avgTime - results.withoutGC.avgTime) / results.withoutGC.avgTime * 100);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Summary
|
||||
t.comment('\n=== PERF-05: Memory Usage Profiling Test Summary ===');
|
||||
|
||||
t.comment('\nBaseline Memory Usage:');
|
||||
baselineMemoryUsage.result.operations.forEach(op => {
|
||||
t.comment(` ${op.name}:`);
|
||||
t.comment(` - Heap before: ${op.heapUsedBefore}MB, after: ${op.heapUsedAfter}MB`);
|
||||
t.comment(` - Heap increase: ${op.heapIncrease}MB`);
|
||||
t.comment(` - RSS increase: ${op.rssIncrease}MB`);
|
||||
});
|
||||
|
||||
t.comment('\nMemory Scaling with Invoice Complexity:');
|
||||
t.comment(' Item Count | Invoice Size | Memory Used | Memory/Item | Efficiency');
|
||||
t.comment(' -----------|--------------|-------------|-------------|------------');
|
||||
memoryScaling.result.scalingData.forEach(data => {
|
||||
t.comment(` ${String(data.itemCount).padEnd(10)} | ${data.invoiceSizeKB.padEnd(12)}KB | ${data.memoryUsedMB.padEnd(11)}MB | ${data.memoryPerItemKB.padEnd(11)}KB | ${data.memoryEfficiency}`);
|
||||
});
|
||||
if (memoryScaling.result.memoryFormula) {
|
||||
t.comment(` Memory scaling formula: ${memoryScaling.result.memoryFormula.formula}`);
|
||||
}
|
||||
|
||||
t.comment('\nMemory Leak Detection:');
|
||||
t.comment(` Iterations: ${memoryLeakDetection.result.iterations}`);
|
||||
t.comment(` Leak detected: ${memoryLeakDetection.result.leakDetected ? 'YES ⚠️' : 'NO ✅'}`);
|
||||
t.comment(` Leak rate: ${(memoryLeakDetection.result.leakRate * 1000).toFixed(3)}KB per iteration`);
|
||||
if (memoryLeakDetection.result.trend) {
|
||||
t.comment(` Memory trend: ${memoryLeakDetection.result.trend.increasing ? 'INCREASING ⚠️' : 'STABLE ✅'}`);
|
||||
t.comment(` - First half avg: ${memoryLeakDetection.result.trend.firstHalfAvgMB}MB`);
|
||||
t.comment(` - Second half avg: ${memoryLeakDetection.result.trend.secondHalfAvgMB}MB`);
|
||||
}
|
||||
|
||||
t.comment('\nCorpus Memory Profile:');
|
||||
t.comment(` Files processed: ${corpusMemoryProfile.result.filesProcessed}`);
|
||||
t.comment(` Total allocated: ${corpusMemoryProfile.result.totalAllocatedMB}MB`);
|
||||
t.comment(` Peak memory: ${corpusMemoryProfile.result.peakMemoryMB}MB`);
|
||||
t.comment(` Avg per file: ${corpusMemoryProfile.result.avgMemoryPerFileMB}MB`);
|
||||
t.comment(' By format:');
|
||||
corpusMemoryProfile.result.formatStats.forEach(stat => {
|
||||
t.comment(` - ${stat.format}: ${stat.count} files, avg ${stat.avgMemoryMB}MB`);
|
||||
});
|
||||
t.comment(' By size:');
|
||||
['small', 'medium', 'large'].forEach(size => {
|
||||
const stats = corpusMemoryProfile.result.sizeStats[size];
|
||||
if (stats.count > 0) {
|
||||
t.comment(` - ${size}: ${stats.count} files, avg ${stats.avgMemory}MB`);
|
||||
}
|
||||
});
|
||||
|
||||
t.comment('\nGarbage Collection Impact:');
|
||||
if (gcImpact.result.withManualGC.avgTime > 0) {
|
||||
t.comment(` With manual GC: ${gcImpact.result.withManualGC.avgTime.toFixed(3)}ms avg`);
|
||||
}
|
||||
t.comment(` Without GC: ${gcImpact.result.withoutGC.avgTime.toFixed(3)}ms avg`);
|
||||
if (gcImpact.result.gcOverhead !== 0) {
|
||||
t.comment(` GC overhead: ${gcImpact.result.gcOverhead.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
// Performance targets check
|
||||
t.comment('\n=== Performance Targets Check ===');
|
||||
const avgMemoryPerInvoice = parseFloat(corpusMemoryProfile.result.avgMemoryPerFileMB);
|
||||
const targetMemory = 100; // Target: <100MB per invoice
|
||||
const leakDetected = memoryLeakDetection.result.leakDetected;
|
||||
|
||||
t.comment(`Memory usage: ${avgMemoryPerInvoice}MB ${avgMemoryPerInvoice < targetMemory ? '✅' : '⚠️'} (target: <${targetMemory}MB per invoice)`);
|
||||
t.comment(`Memory leaks: ${leakDetected ? 'DETECTED ⚠️' : 'NONE ✅'}`);
|
||||
|
||||
// Overall performance summary
|
||||
t.comment('\n=== Overall Performance Summary ===');
|
||||
performanceTracker.logSummary();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.start();
|
669
test/suite/einvoice_performance/test.perf-06.cpu-utilization.ts
Normal file
669
test/suite/einvoice_performance/test.perf-06.cpu-utilization.ts
Normal file
@ -0,0 +1,669 @@
|
||||
/**
|
||||
* @file test.perf-06.cpu-utilization.ts
|
||||
* @description Performance tests for CPU utilization monitoring
|
||||
*/
|
||||
|
||||
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';
|
||||
import * as os from 'os';
|
||||
|
||||
const corpusLoader = new CorpusLoader();
|
||||
const performanceTracker = new PerformanceTracker('PERF-06: CPU Utilization');
|
||||
|
||||
tap.test('PERF-06: CPU Utilization - should maintain efficient CPU usage patterns', async (t) => {
|
||||
// Helper function to get CPU usage
|
||||
const getCPUUsage = () => {
|
||||
const cpus = os.cpus();
|
||||
let user = 0;
|
||||
let nice = 0;
|
||||
let sys = 0;
|
||||
let idle = 0;
|
||||
let irq = 0;
|
||||
|
||||
for (const cpu of cpus) {
|
||||
user += cpu.times.user;
|
||||
nice += cpu.times.nice;
|
||||
sys += cpu.times.sys;
|
||||
idle += cpu.times.idle;
|
||||
irq += cpu.times.irq;
|
||||
}
|
||||
|
||||
const total = user + nice + sys + idle + irq;
|
||||
|
||||
return {
|
||||
user: user / total,
|
||||
system: sys / total,
|
||||
idle: idle / total,
|
||||
total: total
|
||||
};
|
||||
};
|
||||
|
||||
// Test 1: CPU usage baseline for operations
|
||||
const cpuBaseline = await performanceTracker.measureAsync(
|
||||
'cpu-usage-baseline',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
operations: [],
|
||||
cpuCount: os.cpus().length,
|
||||
cpuModel: os.cpus()[0]?.model || 'Unknown'
|
||||
};
|
||||
|
||||
// Operations to test
|
||||
const operations = [
|
||||
{
|
||||
name: 'Idle baseline',
|
||||
fn: async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Format detection (100x)',
|
||||
fn: async () => {
|
||||
const xml = '<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ID>CPU-TEST</ID></Invoice>';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await einvoice.detectFormat(xml);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'XML parsing (50x)',
|
||||
fn: async () => {
|
||||
const xml = `<?xml version="1.0"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>CPU-PARSE</ID>
|
||||
<IssueDate>2024-01-01</IssueDate>
|
||||
${Array(20).fill('<InvoiceLine><ID>Line</ID></InvoiceLine>').join('\n')}
|
||||
</Invoice>`;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await einvoice.parseInvoice(xml, 'ubl');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Validation (30x)',
|
||||
fn: async () => {
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'CPU-VAL-001',
|
||||
issueDate: '2024-02-15',
|
||||
seller: { name: 'CPU Test Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: 'CPU Test Buyer', address: 'Address', country: 'US', taxId: 'US456' },
|
||||
items: Array.from({ length: 20 }, (_, i) => ({
|
||||
description: `Item ${i + 1}`,
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 10,
|
||||
lineTotal: 100
|
||||
})),
|
||||
totals: { netAmount: 2000, vatAmount: 200, grossAmount: 2200 }
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await einvoice.validateInvoice(invoice);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Conversion (20x)',
|
||||
fn: async () => {
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'CPU-CONV-001',
|
||||
issueDate: '2024-02-15',
|
||||
seller: { name: 'Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: 'Buyer', address: 'Address', country: 'US', taxId: 'US456' },
|
||||
items: Array.from({ length: 10 }, (_, i) => ({
|
||||
description: `Item ${i + 1}`,
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 10,
|
||||
lineTotal: 100
|
||||
})),
|
||||
totals: { netAmount: 1000, vatAmount: 100, grossAmount: 1100 }
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await einvoice.convertFormat(invoice, 'cii');
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Execute operations and measure CPU
|
||||
for (const operation of operations) {
|
||||
const startCPU = getCPUUsage();
|
||||
const startTime = Date.now();
|
||||
const startUsage = process.cpuUsage();
|
||||
|
||||
await operation.fn();
|
||||
|
||||
const endUsage = process.cpuUsage(startUsage);
|
||||
const endTime = Date.now();
|
||||
const endCPU = getCPUUsage();
|
||||
|
||||
const duration = endTime - startTime;
|
||||
const userCPU = endUsage.user / 1000; // Convert to milliseconds
|
||||
const systemCPU = endUsage.system / 1000;
|
||||
|
||||
results.operations.push({
|
||||
name: operation.name,
|
||||
duration,
|
||||
userCPU: userCPU.toFixed(2),
|
||||
systemCPU: systemCPU.toFixed(2),
|
||||
totalCPU: (userCPU + systemCPU).toFixed(2),
|
||||
cpuPercentage: ((userCPU + systemCPU) / duration * 100).toFixed(2),
|
||||
efficiency: (duration / (userCPU + systemCPU)).toFixed(2)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 2: Multi-core utilization
|
||||
const multiCoreUtilization = await performanceTracker.measureAsync(
|
||||
'multi-core-utilization',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
coreCount: os.cpus().length,
|
||||
parallelTests: []
|
||||
};
|
||||
|
||||
// Test invoice batch
|
||||
const invoices = Array.from({ length: 50 }, (_, i) => ({
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `MULTI-CORE-${i + 1}`,
|
||||
issueDate: '2024-02-15',
|
||||
seller: { name: `Seller ${i + 1}`, address: 'Address', country: 'US', taxId: `US${i}` },
|
||||
buyer: { name: `Buyer ${i + 1}`, address: 'Address', country: 'US', taxId: `US${i + 1000}` },
|
||||
items: Array.from({ length: 10 }, (_, j) => ({
|
||||
description: `Item ${j + 1}`,
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 10,
|
||||
lineTotal: 100
|
||||
})),
|
||||
totals: { netAmount: 1000, vatAmount: 100, grossAmount: 1100 }
|
||||
}
|
||||
}));
|
||||
|
||||
// Test different parallelism levels
|
||||
const parallelismLevels = [1, 2, 4, 8, results.coreCount];
|
||||
|
||||
for (const parallelism of parallelismLevels) {
|
||||
if (parallelism > results.coreCount) continue;
|
||||
|
||||
const startUsage = process.cpuUsage();
|
||||
const startTime = Date.now();
|
||||
|
||||
// Process invoices in parallel
|
||||
const batchSize = Math.ceil(invoices.length / parallelism);
|
||||
const promises = [];
|
||||
|
||||
for (let i = 0; i < parallelism; i++) {
|
||||
const batch = invoices.slice(i * batchSize, (i + 1) * batchSize);
|
||||
promises.push(
|
||||
Promise.all(batch.map(async (invoice) => {
|
||||
await einvoice.validateInvoice(invoice);
|
||||
await einvoice.convertFormat(invoice, 'cii');
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
const endTime = Date.now();
|
||||
const endUsage = process.cpuUsage(startUsage);
|
||||
|
||||
const duration = endTime - startTime;
|
||||
const totalCPU = (endUsage.user + endUsage.system) / 1000;
|
||||
const theoreticalSpeedup = parallelism;
|
||||
const actualSpeedup = results.parallelTests.length > 0 ?
|
||||
results.parallelTests[0].duration / duration : 1;
|
||||
|
||||
results.parallelTests.push({
|
||||
parallelism,
|
||||
duration,
|
||||
totalCPU: totalCPU.toFixed(2),
|
||||
cpuEfficiency: ((totalCPU / duration) * 100).toFixed(2),
|
||||
theoreticalSpeedup,
|
||||
actualSpeedup: actualSpeedup.toFixed(2),
|
||||
efficiency: ((actualSpeedup / theoreticalSpeedup) * 100).toFixed(2)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 3: CPU-intensive operations profiling
|
||||
const cpuIntensiveOperations = await performanceTracker.measureAsync(
|
||||
'cpu-intensive-operations',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
operations: []
|
||||
};
|
||||
|
||||
// Test scenarios
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'Complex validation',
|
||||
fn: async () => {
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'COMPLEX-VAL-001',
|
||||
issueDate: '2024-02-15',
|
||||
dueDate: '2024-03-15',
|
||||
currency: 'EUR',
|
||||
seller: {
|
||||
name: 'Complex Validation Test Seller GmbH',
|
||||
address: 'Hauptstraße 123',
|
||||
city: 'Berlin',
|
||||
postalCode: '10115',
|
||||
country: 'DE',
|
||||
taxId: 'DE123456789',
|
||||
registrationNumber: 'HRB12345',
|
||||
email: 'billing@seller.de',
|
||||
phone: '+49 30 12345678'
|
||||
},
|
||||
buyer: {
|
||||
name: 'Complex Validation Test Buyer Ltd',
|
||||
address: 'Business Street 456',
|
||||
city: 'Munich',
|
||||
postalCode: '80331',
|
||||
country: 'DE',
|
||||
taxId: 'DE987654321',
|
||||
email: 'ap@buyer.de'
|
||||
},
|
||||
items: Array.from({ length: 100 }, (_, i) => ({
|
||||
description: `Complex Product ${i + 1} with detailed specifications and compliance requirements`,
|
||||
quantity: Math.floor(Math.random() * 100) + 1,
|
||||
unitPrice: Math.random() * 1000,
|
||||
vatRate: [0, 7, 19][Math.floor(Math.random() * 3)],
|
||||
lineTotal: 0,
|
||||
itemId: `ITEM-${String(i + 1).padStart(5, '0')}`,
|
||||
additionalCharges: Math.random() * 50,
|
||||
discounts: Math.random() * 20
|
||||
})),
|
||||
totals: { netAmount: 0, vatAmount: 0, grossAmount: 0 }
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate totals
|
||||
invoice.data.items.forEach(item => {
|
||||
item.lineTotal = item.quantity * item.unitPrice + (item.additionalCharges || 0) - (item.discounts || 0);
|
||||
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;
|
||||
|
||||
// Perform all validation levels
|
||||
await einvoice.validateInvoice(invoice, { level: 'syntax' });
|
||||
await einvoice.validateInvoice(invoice, { level: 'semantic' });
|
||||
await einvoice.validateInvoice(invoice, { level: 'business' });
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Large XML generation',
|
||||
fn: async () => {
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'LARGE-XML-001',
|
||||
issueDate: '2024-02-15',
|
||||
seller: { name: 'XML Generator Corp', address: 'XML Street', country: 'US', taxId: 'US123456789' },
|
||||
buyer: { name: 'XML Consumer Inc', address: 'XML Avenue', country: 'US', taxId: 'US987654321' },
|
||||
items: Array.from({ length: 200 }, (_, i) => ({
|
||||
description: `Product ${i + 1} with very long description `.repeat(10),
|
||||
quantity: Math.random() * 100,
|
||||
unitPrice: Math.random() * 1000,
|
||||
vatRate: Math.random() * 25,
|
||||
lineTotal: 0
|
||||
})),
|
||||
totals: { netAmount: 0, vatAmount: 0, grossAmount: 0 }
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate totals
|
||||
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;
|
||||
|
||||
await einvoice.generateXML(invoice);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Chain conversions',
|
||||
fn: async () => {
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'CHAIN-CONV-001',
|
||||
issueDate: '2024-02-15',
|
||||
seller: { name: 'Chain Seller', address: 'Chain Street', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: 'Chain Buyer', address: 'Chain Avenue', country: 'US', taxId: 'US456' },
|
||||
items: Array.from({ length: 50 }, (_, i) => ({
|
||||
description: `Chain Item ${i + 1}`,
|
||||
quantity: i + 1,
|
||||
unitPrice: 100 + i * 10,
|
||||
vatRate: 10,
|
||||
lineTotal: (i + 1) * (100 + i * 10)
|
||||
})),
|
||||
totals: { netAmount: 0, vatAmount: 0, grossAmount: 0 }
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate totals
|
||||
invoice.data.items.forEach(item => {
|
||||
invoice.data.totals.netAmount += item.lineTotal;
|
||||
invoice.data.totals.vatAmount += item.lineTotal * 0.1;
|
||||
});
|
||||
invoice.data.totals.grossAmount = invoice.data.totals.netAmount + invoice.data.totals.vatAmount;
|
||||
|
||||
// Chain conversions
|
||||
let current = invoice;
|
||||
const formats = ['cii', 'zugferd', 'xrechnung', 'ubl'];
|
||||
|
||||
for (const format of formats) {
|
||||
current = await einvoice.convertFormat(current, format);
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Profile each scenario
|
||||
for (const scenario of scenarios) {
|
||||
const iterations = 5;
|
||||
const measurements = [];
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const startUsage = process.cpuUsage();
|
||||
const startTime = process.hrtime.bigint();
|
||||
|
||||
await scenario.fn();
|
||||
|
||||
const endTime = process.hrtime.bigint();
|
||||
const endUsage = process.cpuUsage(startUsage);
|
||||
|
||||
const duration = Number(endTime - startTime) / 1_000_000;
|
||||
const cpuTime = (endUsage.user + endUsage.system) / 1000;
|
||||
|
||||
measurements.push({
|
||||
duration,
|
||||
cpuTime,
|
||||
efficiency: cpuTime / duration
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
const avgDuration = measurements.reduce((sum, m) => sum + m.duration, 0) / iterations;
|
||||
const avgCpuTime = measurements.reduce((sum, m) => sum + m.cpuTime, 0) / iterations;
|
||||
const avgEfficiency = measurements.reduce((sum, m) => sum + m.efficiency, 0) / iterations;
|
||||
|
||||
results.operations.push({
|
||||
name: scenario.name,
|
||||
iterations,
|
||||
avgDuration: avgDuration.toFixed(2),
|
||||
avgCpuTime: avgCpuTime.toFixed(2),
|
||||
avgEfficiency: (avgEfficiency * 100).toFixed(2),
|
||||
cpuIntensity: avgCpuTime > avgDuration * 0.8 ? 'HIGH' :
|
||||
avgCpuTime > avgDuration * 0.5 ? 'MEDIUM' : 'LOW'
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 4: Corpus processing CPU profile
|
||||
const corpusCPUProfile = await performanceTracker.measureAsync(
|
||||
'corpus-cpu-profile',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
filesProcessed: 0,
|
||||
totalCPUTime: 0,
|
||||
totalWallTime: 0,
|
||||
cpuByOperation: {
|
||||
detection: { time: 0, count: 0 },
|
||||
parsing: { time: 0, count: 0 },
|
||||
validation: { time: 0, count: 0 },
|
||||
conversion: { time: 0, count: 0 }
|
||||
}
|
||||
};
|
||||
|
||||
// Sample files
|
||||
const sampleFiles = files.slice(0, 25);
|
||||
const overallStart = Date.now();
|
||||
|
||||
for (const file of sampleFiles) {
|
||||
try {
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
|
||||
// Format detection
|
||||
let startUsage = process.cpuUsage();
|
||||
const format = await einvoice.detectFormat(content);
|
||||
let endUsage = process.cpuUsage(startUsage);
|
||||
results.cpuByOperation.detection.time += (endUsage.user + endUsage.system) / 1000;
|
||||
results.cpuByOperation.detection.count++;
|
||||
|
||||
if (!format || format === 'unknown') continue;
|
||||
|
||||
// Parsing
|
||||
startUsage = process.cpuUsage();
|
||||
const invoice = await einvoice.parseInvoice(content, format);
|
||||
endUsage = process.cpuUsage(startUsage);
|
||||
results.cpuByOperation.parsing.time += (endUsage.user + endUsage.system) / 1000;
|
||||
results.cpuByOperation.parsing.count++;
|
||||
|
||||
// Validation
|
||||
startUsage = process.cpuUsage();
|
||||
await einvoice.validateInvoice(invoice);
|
||||
endUsage = process.cpuUsage(startUsage);
|
||||
results.cpuByOperation.validation.time += (endUsage.user + endUsage.system) / 1000;
|
||||
results.cpuByOperation.validation.count++;
|
||||
|
||||
// Conversion
|
||||
const targetFormat = format === 'ubl' ? 'cii' : 'ubl';
|
||||
startUsage = process.cpuUsage();
|
||||
await einvoice.convertFormat(invoice, targetFormat);
|
||||
endUsage = process.cpuUsage(startUsage);
|
||||
results.cpuByOperation.conversion.time += (endUsage.user + endUsage.system) / 1000;
|
||||
results.cpuByOperation.conversion.count++;
|
||||
|
||||
results.filesProcessed++;
|
||||
|
||||
} catch (error) {
|
||||
// Skip failed files
|
||||
}
|
||||
}
|
||||
|
||||
results.totalWallTime = Date.now() - overallStart;
|
||||
|
||||
// Calculate totals and averages
|
||||
for (const op of Object.keys(results.cpuByOperation)) {
|
||||
const opData = results.cpuByOperation[op];
|
||||
results.totalCPUTime += opData.time;
|
||||
}
|
||||
|
||||
return {
|
||||
filesProcessed: results.filesProcessed,
|
||||
totalWallTime: results.totalWallTime,
|
||||
totalCPUTime: results.totalCPUTime.toFixed(2),
|
||||
cpuEfficiency: ((results.totalCPUTime / results.totalWallTime) * 100).toFixed(2),
|
||||
operations: Object.entries(results.cpuByOperation).map(([op, data]) => ({
|
||||
operation: op,
|
||||
totalTime: data.time.toFixed(2),
|
||||
avgTime: data.count > 0 ? (data.time / data.count).toFixed(3) : 'N/A',
|
||||
percentage: ((data.time / results.totalCPUTime) * 100).toFixed(1)
|
||||
}))
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Test 5: Sustained CPU load test
|
||||
const sustainedCPULoad = await performanceTracker.measureAsync(
|
||||
'sustained-cpu-load',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const testDuration = 5000; // 5 seconds
|
||||
const results = {
|
||||
samples: [],
|
||||
avgCPUUsage: 0,
|
||||
peakCPUUsage: 0,
|
||||
consistency: 0
|
||||
};
|
||||
|
||||
// Test invoice
|
||||
const testInvoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'SUSTAINED-CPU-001',
|
||||
issueDate: '2024-02-15',
|
||||
seller: { name: 'CPU Load Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: 'CPU Load Buyer', address: 'Address', country: 'US', taxId: 'US456' },
|
||||
items: Array.from({ length: 20 }, (_, i) => ({
|
||||
description: `Item ${i + 1}`,
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 10,
|
||||
lineTotal: 100
|
||||
})),
|
||||
totals: { netAmount: 2000, vatAmount: 200, grossAmount: 2200 }
|
||||
}
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
let sampleCount = 0;
|
||||
|
||||
// Run sustained load
|
||||
while (Date.now() - startTime < testDuration) {
|
||||
const sampleStart = process.cpuUsage();
|
||||
const sampleStartTime = Date.now();
|
||||
|
||||
// Perform operations
|
||||
await einvoice.validateInvoice(testInvoice);
|
||||
await einvoice.convertFormat(testInvoice, 'cii');
|
||||
|
||||
const sampleEndTime = Date.now();
|
||||
const sampleEnd = process.cpuUsage(sampleStart);
|
||||
|
||||
const sampleDuration = sampleEndTime - sampleStartTime;
|
||||
const cpuTime = (sampleEnd.user + sampleEnd.system) / 1000;
|
||||
const cpuUsage = (cpuTime / sampleDuration) * 100;
|
||||
|
||||
results.samples.push(cpuUsage);
|
||||
|
||||
if (cpuUsage > results.peakCPUUsage) {
|
||||
results.peakCPUUsage = cpuUsage;
|
||||
}
|
||||
|
||||
sampleCount++;
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
if (results.samples.length > 0) {
|
||||
results.avgCPUUsage = results.samples.reduce((a, b) => a + b, 0) / results.samples.length;
|
||||
|
||||
// Calculate standard deviation for consistency
|
||||
const variance = results.samples.reduce((sum, val) =>
|
||||
sum + Math.pow(val - results.avgCPUUsage, 2), 0) / results.samples.length;
|
||||
const stdDev = Math.sqrt(variance);
|
||||
results.consistency = 100 - (stdDev / results.avgCPUUsage * 100);
|
||||
}
|
||||
|
||||
return {
|
||||
duration: Date.now() - startTime,
|
||||
samples: results.samples.length,
|
||||
avgCPUUsage: results.avgCPUUsage.toFixed(2),
|
||||
peakCPUUsage: results.peakCPUUsage.toFixed(2),
|
||||
consistency: results.consistency.toFixed(2),
|
||||
stable: results.consistency > 80
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Summary
|
||||
t.comment('\n=== PERF-06: CPU Utilization Test Summary ===');
|
||||
|
||||
t.comment('\nCPU Baseline:');
|
||||
t.comment(` System: ${cpuBaseline.result.cpuCount} cores, ${cpuBaseline.result.cpuModel}`);
|
||||
t.comment(' Operation benchmarks:');
|
||||
cpuBaseline.result.operations.forEach(op => {
|
||||
t.comment(` ${op.name}:`);
|
||||
t.comment(` - Duration: ${op.duration}ms`);
|
||||
t.comment(` - CPU time: ${op.totalCPU}ms (user: ${op.userCPU}ms, system: ${op.systemCPU}ms)`);
|
||||
t.comment(` - CPU usage: ${op.cpuPercentage}%`);
|
||||
t.comment(` - Efficiency: ${op.efficiency}x`);
|
||||
});
|
||||
|
||||
t.comment('\nMulti-Core Utilization:');
|
||||
t.comment(' Parallelism | Duration | CPU Time | Efficiency | Speedup | Scaling');
|
||||
t.comment(' ------------|----------|----------|------------|---------|--------');
|
||||
multiCoreUtilization.result.parallelTests.forEach(test => {
|
||||
t.comment(` ${String(test.parallelism).padEnd(11)} | ${String(test.duration + 'ms').padEnd(8)} | ${test.totalCPU.padEnd(8)}ms | ${test.cpuEfficiency.padEnd(10)}% | ${test.actualSpeedup.padEnd(7)}x | ${test.efficiency}%`);
|
||||
});
|
||||
|
||||
t.comment('\nCPU-Intensive Operations:');
|
||||
cpuIntensiveOperations.result.operations.forEach(op => {
|
||||
t.comment(` ${op.name}:`);
|
||||
t.comment(` - Avg duration: ${op.avgDuration}ms`);
|
||||
t.comment(` - Avg CPU time: ${op.avgCpuTime}ms`);
|
||||
t.comment(` - CPU efficiency: ${op.avgEfficiency}%`);
|
||||
t.comment(` - Intensity: ${op.cpuIntensity}`);
|
||||
});
|
||||
|
||||
t.comment('\nCorpus CPU Profile:');
|
||||
t.comment(` Files processed: ${corpusCPUProfile.result.filesProcessed}`);
|
||||
t.comment(` Total wall time: ${corpusCPUProfile.result.totalWallTime}ms`);
|
||||
t.comment(` Total CPU time: ${corpusCPUProfile.result.totalCPUTime}ms`);
|
||||
t.comment(` CPU efficiency: ${corpusCPUProfile.result.cpuEfficiency}%`);
|
||||
t.comment(' By operation:');
|
||||
corpusCPUProfile.result.operations.forEach(op => {
|
||||
t.comment(` - ${op.operation}: ${op.totalTime}ms (${op.percentage}%), avg ${op.avgTime}ms`);
|
||||
});
|
||||
|
||||
t.comment('\nSustained CPU Load (5 seconds):');
|
||||
t.comment(` Samples: ${sustainedCPULoad.result.samples}`);
|
||||
t.comment(` Average CPU usage: ${sustainedCPULoad.result.avgCPUUsage}%`);
|
||||
t.comment(` Peak CPU usage: ${sustainedCPULoad.result.peakCPUUsage}%`);
|
||||
t.comment(` Consistency: ${sustainedCPULoad.result.consistency}%`);
|
||||
t.comment(` Stable performance: ${sustainedCPULoad.result.stable ? 'YES ✅' : 'NO ⚠️'}`);
|
||||
|
||||
// Performance targets check
|
||||
t.comment('\n=== Performance Targets Check ===');
|
||||
const avgCPUEfficiency = parseFloat(corpusCPUProfile.result.cpuEfficiency);
|
||||
const cpuStability = sustainedCPULoad.result.stable;
|
||||
|
||||
t.comment(`CPU efficiency: ${avgCPUEfficiency}% ${avgCPUEfficiency > 50 ? '✅' : '⚠️'} (target: >50%)`);
|
||||
t.comment(`CPU stability: ${cpuStability ? 'STABLE ✅' : 'UNSTABLE ⚠️'}`);
|
||||
|
||||
// Overall performance summary
|
||||
t.comment('\n=== Overall Performance Summary ===');
|
||||
performanceTracker.logSummary();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.start();
|
@ -0,0 +1,663 @@
|
||||
/**
|
||||
* @file test.perf-07.concurrent-processing.ts
|
||||
* @description Performance tests for concurrent processing capabilities
|
||||
*/
|
||||
|
||||
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';
|
||||
import * as os from 'os';
|
||||
|
||||
const corpusLoader = new CorpusLoader();
|
||||
const performanceTracker = new PerformanceTracker('PERF-07: Concurrent Processing');
|
||||
|
||||
tap.test('PERF-07: Concurrent Processing - should handle concurrent operations efficiently', async (t) => {
|
||||
// Test 1: Concurrent format detection
|
||||
const concurrentDetection = await performanceTracker.measureAsync(
|
||||
'concurrent-format-detection',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
concurrencyLevels: [],
|
||||
optimalConcurrency: 0,
|
||||
maxThroughput: 0
|
||||
};
|
||||
|
||||
// Create test data with different formats
|
||||
const testData = [
|
||||
...Array(25).fill(null).map((_, i) => ({
|
||||
id: `ubl-${i}`,
|
||||
content: `<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ID>UBL-${i}</ID></Invoice>`
|
||||
})),
|
||||
...Array(25).fill(null).map((_, i) => ({
|
||||
id: `cii-${i}`,
|
||||
content: `<?xml version="1.0"?><rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"><rsm:ExchangedDocument><ram:ID>CII-${i}</ram:ID></rsm:ExchangedDocument></rsm:CrossIndustryInvoice>`
|
||||
})),
|
||||
...Array(25).fill(null).map((_, i) => ({
|
||||
id: `unknown-${i}`,
|
||||
content: `<?xml version="1.0"?><UnknownRoot><ID>UNKNOWN-${i}</ID></UnknownRoot>`
|
||||
}))
|
||||
];
|
||||
|
||||
// Test different concurrency levels
|
||||
const levels = [1, 2, 4, 8, 16, 32, 64];
|
||||
|
||||
for (const concurrency of levels) {
|
||||
const startTime = Date.now();
|
||||
let completed = 0;
|
||||
let correct = 0;
|
||||
|
||||
// Process in batches
|
||||
const batchSize = concurrency;
|
||||
const batches = [];
|
||||
|
||||
for (let i = 0; i < testData.length; i += batchSize) {
|
||||
batches.push(testData.slice(i, i + batchSize));
|
||||
}
|
||||
|
||||
for (const batch of batches) {
|
||||
const promises = batch.map(async (item) => {
|
||||
const format = await einvoice.detectFormat(item.content);
|
||||
completed++;
|
||||
|
||||
// Verify correctness
|
||||
if ((item.id.startsWith('ubl') && format === 'ubl') ||
|
||||
(item.id.startsWith('cii') && format === 'cii') ||
|
||||
(item.id.startsWith('unknown') && format === 'unknown')) {
|
||||
correct++;
|
||||
}
|
||||
|
||||
return format;
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
const throughput = (completed / (duration / 1000));
|
||||
|
||||
const result = {
|
||||
concurrency,
|
||||
duration,
|
||||
completed,
|
||||
correct,
|
||||
accuracy: ((correct / completed) * 100).toFixed(2),
|
||||
throughput: throughput.toFixed(2),
|
||||
avgLatency: (duration / completed).toFixed(2)
|
||||
};
|
||||
|
||||
results.concurrencyLevels.push(result);
|
||||
|
||||
if (throughput > results.maxThroughput) {
|
||||
results.maxThroughput = throughput;
|
||||
results.optimalConcurrency = concurrency;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 2: Concurrent validation
|
||||
const concurrentValidation = await performanceTracker.measureAsync(
|
||||
'concurrent-validation',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
scenarios: [],
|
||||
resourceContention: null
|
||||
};
|
||||
|
||||
// Create test invoices with varying complexity
|
||||
const createInvoice = (id: number, complexity: 'simple' | 'medium' | 'complex') => {
|
||||
const itemCount = complexity === 'simple' ? 5 : complexity === 'medium' ? 20 : 50;
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `CONC-VAL-${complexity}-${id}`,
|
||||
issueDate: '2024-02-20',
|
||||
seller: { name: `Seller ${id}`, address: 'Address', country: 'US', taxId: `US${id}` },
|
||||
buyer: { name: `Buyer ${id}`, address: 'Address', country: 'US', taxId: `US${id + 1000}` },
|
||||
items: Array.from({ length: itemCount }, (_, i) => ({
|
||||
description: `Item ${i + 1} for invoice ${id}`,
|
||||
quantity: Math.random() * 10,
|
||||
unitPrice: Math.random() * 100,
|
||||
vatRate: [5, 10, 15, 20][Math.floor(Math.random() * 4)],
|
||||
lineTotal: 0
|
||||
})),
|
||||
totals: { netAmount: 0, vatAmount: 0, grossAmount: 0 }
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate totals
|
||||
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;
|
||||
|
||||
return invoice;
|
||||
};
|
||||
|
||||
// Test scenarios
|
||||
const scenarios = [
|
||||
{ name: 'All simple', distribution: { simple: 30, medium: 0, complex: 0 } },
|
||||
{ name: 'Mixed load', distribution: { simple: 10, medium: 15, complex: 5 } },
|
||||
{ name: 'All complex', distribution: { simple: 0, medium: 0, complex: 30 } }
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const invoices = [];
|
||||
let id = 0;
|
||||
|
||||
// Create invoices according to distribution
|
||||
for (const [complexity, count] of Object.entries(scenario.distribution)) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
invoices.push(createInvoice(id++, complexity as any));
|
||||
}
|
||||
}
|
||||
|
||||
// Test with optimal concurrency from previous test
|
||||
const concurrency = concurrentDetection.result.optimalConcurrency || 8;
|
||||
const startTime = Date.now();
|
||||
const startCPU = process.cpuUsage();
|
||||
|
||||
// Process concurrently
|
||||
const results = [];
|
||||
for (let i = 0; i < invoices.length; i += concurrency) {
|
||||
const batch = invoices.slice(i, i + concurrency);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (invoice) => {
|
||||
const start = Date.now();
|
||||
const result = await einvoice.validateInvoice(invoice);
|
||||
return {
|
||||
duration: Date.now() - start,
|
||||
valid: result.isValid,
|
||||
errors: result.errors?.length || 0
|
||||
};
|
||||
})
|
||||
);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - startTime;
|
||||
const cpuUsage = process.cpuUsage(startCPU);
|
||||
|
||||
// Analyze results
|
||||
const validCount = results.filter(r => r.valid).length;
|
||||
const avgDuration = results.reduce((sum, r) => sum + r.duration, 0) / results.length;
|
||||
const maxDuration = Math.max(...results.map(r => r.duration));
|
||||
|
||||
results.scenarios.push({
|
||||
name: scenario.name,
|
||||
invoiceCount: invoices.length,
|
||||
concurrency,
|
||||
totalDuration,
|
||||
throughput: (invoices.length / (totalDuration / 1000)).toFixed(2),
|
||||
validCount,
|
||||
validationRate: ((validCount / invoices.length) * 100).toFixed(2),
|
||||
avgLatency: avgDuration.toFixed(2),
|
||||
maxLatency: maxDuration,
|
||||
cpuTime: ((cpuUsage.user + cpuUsage.system) / 1000).toFixed(2),
|
||||
cpuEfficiency: (((cpuUsage.user + cpuUsage.system) / 1000) / totalDuration * 100).toFixed(2)
|
||||
});
|
||||
}
|
||||
|
||||
// Test resource contention
|
||||
const contentionTest = async () => {
|
||||
const invoice = createInvoice(9999, 'medium');
|
||||
const concurrencyLevels = [1, 10, 50, 100];
|
||||
const results = [];
|
||||
|
||||
for (const level of concurrencyLevels) {
|
||||
const start = Date.now();
|
||||
const promises = Array(level).fill(null).map(() =>
|
||||
einvoice.validateInvoice(invoice)
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
results.push({
|
||||
concurrency: level,
|
||||
totalTime: duration,
|
||||
avgTime: (duration / level).toFixed(2),
|
||||
throughput: (level / (duration / 1000)).toFixed(2)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
results.resourceContention = await contentionTest();
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 3: Concurrent file processing
|
||||
const concurrentFileProcessing = await performanceTracker.measureAsync(
|
||||
'concurrent-file-processing',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
fileCount: 0,
|
||||
processedCount: 0,
|
||||
concurrencyTests: [],
|
||||
errorRates: new Map<number, number>()
|
||||
};
|
||||
|
||||
// Sample files
|
||||
const sampleFiles = files.slice(0, 50);
|
||||
results.fileCount = sampleFiles.length;
|
||||
|
||||
// Test different concurrency strategies
|
||||
const strategies = [
|
||||
{ name: 'Sequential', concurrency: 1 },
|
||||
{ name: 'Conservative', concurrency: 4 },
|
||||
{ name: 'Moderate', concurrency: 8 },
|
||||
{ name: 'Aggressive', concurrency: 16 },
|
||||
{ name: 'Max', concurrency: os.cpus().length * 2 }
|
||||
];
|
||||
|
||||
for (const strategy of strategies) {
|
||||
const startTime = Date.now();
|
||||
const startMemory = process.memoryUsage();
|
||||
let processed = 0;
|
||||
let errors = 0;
|
||||
|
||||
// Process files with specified concurrency
|
||||
const queue = [...sampleFiles];
|
||||
const activePromises = new Set();
|
||||
|
||||
while (queue.length > 0 || activePromises.size > 0) {
|
||||
// Start new tasks up to concurrency limit
|
||||
while (activePromises.size < strategy.concurrency && queue.length > 0) {
|
||||
const file = queue.shift()!;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
const format = await einvoice.detectFormat(content);
|
||||
|
||||
if (format && format !== 'unknown') {
|
||||
const invoice = await einvoice.parseInvoice(content, format);
|
||||
await einvoice.validateInvoice(invoice);
|
||||
processed++;
|
||||
}
|
||||
} catch (error) {
|
||||
errors++;
|
||||
}
|
||||
})();
|
||||
|
||||
activePromises.add(promise);
|
||||
promise.finally(() => activePromises.delete(promise));
|
||||
}
|
||||
|
||||
// Wait for at least one to complete
|
||||
if (activePromises.size > 0) {
|
||||
await Promise.race(activePromises);
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
const endMemory = process.memoryUsage();
|
||||
|
||||
results.concurrencyTests.push({
|
||||
strategy: strategy.name,
|
||||
concurrency: strategy.concurrency,
|
||||
duration,
|
||||
processed,
|
||||
errors,
|
||||
throughput: (processed / (duration / 1000)).toFixed(2),
|
||||
avgFileTime: (duration / sampleFiles.length).toFixed(2),
|
||||
memoryIncrease: ((endMemory.heapUsed - startMemory.heapUsed) / 1024 / 1024).toFixed(2),
|
||||
errorRate: ((errors / sampleFiles.length) * 100).toFixed(2)
|
||||
});
|
||||
|
||||
results.errorRates.set(strategy.concurrency, errors);
|
||||
results.processedCount = Math.max(results.processedCount, processed);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 4: Mixed operation concurrency
|
||||
const mixedOperationConcurrency = await performanceTracker.measureAsync(
|
||||
'mixed-operation-concurrency',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
operations: [],
|
||||
contentionAnalysis: null
|
||||
};
|
||||
|
||||
// Define mixed operations
|
||||
const operations = [
|
||||
{
|
||||
name: 'detect',
|
||||
fn: async (id: number) => {
|
||||
const xml = `<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ID>MIXED-${id}</ID></Invoice>`;
|
||||
return await einvoice.detectFormat(xml);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'parse',
|
||||
fn: async (id: number) => {
|
||||
const xml = `<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ID>PARSE-${id}</ID><IssueDate>2024-01-01</IssueDate></Invoice>`;
|
||||
return await einvoice.parseInvoice(xml, 'ubl');
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'validate',
|
||||
fn: async (id: number) => {
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `VAL-${id}`,
|
||||
issueDate: '2024-02-20',
|
||||
seller: { name: 'Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: '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 }
|
||||
}
|
||||
};
|
||||
return await einvoice.validateInvoice(invoice);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'convert',
|
||||
fn: async (id: number) => {
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `CONV-${id}`,
|
||||
issueDate: '2024-02-20',
|
||||
seller: { name: 'Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: '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 }
|
||||
}
|
||||
};
|
||||
return await einvoice.convertFormat(invoice, 'cii');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Test mixed workload
|
||||
const totalOperations = 200;
|
||||
const operationMix = Array.from({ length: totalOperations }, (_, i) => ({
|
||||
operation: operations[i % operations.length],
|
||||
id: i
|
||||
}));
|
||||
|
||||
// Shuffle to simulate real-world mix
|
||||
for (let i = operationMix.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[operationMix[i], operationMix[j]] = [operationMix[j], operationMix[i]];
|
||||
}
|
||||
|
||||
// Test with different concurrency levels
|
||||
const concurrencyLevels = [1, 5, 10, 20];
|
||||
|
||||
for (const concurrency of concurrencyLevels) {
|
||||
const startTime = Date.now();
|
||||
const operationStats = new Map(operations.map(op => [op.name, { count: 0, totalTime: 0, errors: 0 }]));
|
||||
|
||||
// Process operations
|
||||
for (let i = 0; i < operationMix.length; i += concurrency) {
|
||||
const batch = operationMix.slice(i, i + concurrency);
|
||||
|
||||
await Promise.all(batch.map(async ({ operation, id }) => {
|
||||
const opStart = Date.now();
|
||||
try {
|
||||
await operation.fn(id);
|
||||
operationStats.get(operation.name)!.count++;
|
||||
} catch {
|
||||
operationStats.get(operation.name)!.errors++;
|
||||
}
|
||||
operationStats.get(operation.name)!.totalTime += Date.now() - opStart;
|
||||
}));
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - startTime;
|
||||
|
||||
results.operations.push({
|
||||
concurrency,
|
||||
totalDuration,
|
||||
throughput: (totalOperations / (totalDuration / 1000)).toFixed(2),
|
||||
operationBreakdown: Array.from(operationStats.entries()).map(([name, stats]) => ({
|
||||
operation: name,
|
||||
count: stats.count,
|
||||
avgTime: stats.count > 0 ? (stats.totalTime / stats.count).toFixed(2) : 'N/A',
|
||||
errorRate: ((stats.errors / (stats.count + stats.errors)) * 100).toFixed(2)
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
// Analyze operation contention
|
||||
const contentionTest = async () => {
|
||||
const promises = [];
|
||||
const contentionResults = [];
|
||||
|
||||
// Run all operations concurrently
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (const op of operations) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
const start = Date.now();
|
||||
await op.fn(1000 + i);
|
||||
return { operation: op.name, duration: Date.now() - start };
|
||||
})()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// Group by operation
|
||||
const grouped = results.reduce((acc, r) => {
|
||||
if (!acc[r.operation]) acc[r.operation] = [];
|
||||
acc[r.operation].push(r.duration);
|
||||
return acc;
|
||||
}, {} as Record<string, number[]>);
|
||||
|
||||
for (const [op, durations] of Object.entries(grouped)) {
|
||||
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
|
||||
const min = Math.min(...durations);
|
||||
const max = Math.max(...durations);
|
||||
|
||||
contentionResults.push({
|
||||
operation: op,
|
||||
avgDuration: avg.toFixed(2),
|
||||
minDuration: min,
|
||||
maxDuration: max,
|
||||
variance: ((max - min) / avg * 100).toFixed(2)
|
||||
});
|
||||
}
|
||||
|
||||
return contentionResults;
|
||||
};
|
||||
|
||||
results.contentionAnalysis = await contentionTest();
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 5: Concurrent corpus processing
|
||||
const concurrentCorpusProcessing = await performanceTracker.measureAsync(
|
||||
'concurrent-corpus-processing',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
totalFiles: files.length,
|
||||
processedFiles: 0,
|
||||
formatDistribution: new Map<string, number>(),
|
||||
performanceMetrics: {
|
||||
startTime: Date.now(),
|
||||
endTime: 0,
|
||||
peakConcurrency: 0,
|
||||
avgResponseTime: 0,
|
||||
throughputOverTime: []
|
||||
}
|
||||
};
|
||||
|
||||
// Process entire corpus with optimal concurrency
|
||||
const optimalConcurrency = concurrentDetection.result.optimalConcurrency || 16;
|
||||
const queue = [...files];
|
||||
const activeOperations = new Map<string, { start: number; format?: string }>();
|
||||
const responseTimes = [];
|
||||
|
||||
// Track throughput over time
|
||||
const throughputInterval = setInterval(() => {
|
||||
const elapsed = (Date.now() - results.performanceMetrics.startTime) / 1000;
|
||||
const current = results.processedFiles;
|
||||
results.performanceMetrics.throughputOverTime.push({
|
||||
time: elapsed,
|
||||
throughput: current / elapsed
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
while (queue.length > 0 || activeOperations.size > 0) {
|
||||
// Start new operations
|
||||
while (activeOperations.size < optimalConcurrency && queue.length > 0) {
|
||||
const file = queue.shift()!;
|
||||
const operationId = `op-${Date.now()}-${Math.random()}`;
|
||||
|
||||
activeOperations.set(operationId, { start: Date.now() });
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
const format = await einvoice.detectFormat(content);
|
||||
|
||||
if (format && format !== 'unknown') {
|
||||
activeOperations.get(operationId)!.format = format;
|
||||
results.formatDistribution.set(format,
|
||||
(results.formatDistribution.get(format) || 0) + 1
|
||||
);
|
||||
|
||||
const invoice = await einvoice.parseInvoice(content, format);
|
||||
await einvoice.validateInvoice(invoice);
|
||||
|
||||
results.processedFiles++;
|
||||
}
|
||||
|
||||
const duration = Date.now() - activeOperations.get(operationId)!.start;
|
||||
responseTimes.push(duration);
|
||||
|
||||
} catch (error) {
|
||||
// Skip failed files
|
||||
} finally {
|
||||
activeOperations.delete(operationId);
|
||||
}
|
||||
})();
|
||||
|
||||
if (activeOperations.size > results.performanceMetrics.peakConcurrency) {
|
||||
results.performanceMetrics.peakConcurrency = activeOperations.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for some to complete
|
||||
if (activeOperations.size > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
clearInterval(throughputInterval);
|
||||
results.performanceMetrics.endTime = Date.now();
|
||||
|
||||
// Calculate final metrics
|
||||
const totalDuration = results.performanceMetrics.endTime - results.performanceMetrics.startTime;
|
||||
results.performanceMetrics.avgResponseTime = responseTimes.length > 0 ?
|
||||
responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length : 0;
|
||||
|
||||
return {
|
||||
totalFiles: results.totalFiles,
|
||||
processedFiles: results.processedFiles,
|
||||
successRate: ((results.processedFiles / results.totalFiles) * 100).toFixed(2),
|
||||
totalDuration: totalDuration,
|
||||
overallThroughput: (results.processedFiles / (totalDuration / 1000)).toFixed(2),
|
||||
avgResponseTime: results.performanceMetrics.avgResponseTime.toFixed(2),
|
||||
peakConcurrency: results.performanceMetrics.peakConcurrency,
|
||||
formatDistribution: Array.from(results.formatDistribution.entries()),
|
||||
throughputProgression: results.performanceMetrics.throughputOverTime.slice(-5)
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Summary
|
||||
t.comment('\n=== PERF-07: Concurrent Processing Test Summary ===');
|
||||
|
||||
t.comment('\nConcurrent Format Detection:');
|
||||
t.comment(' Concurrency | Duration | Throughput | Accuracy | Avg Latency');
|
||||
t.comment(' ------------|----------|------------|----------|------------');
|
||||
concurrentDetection.result.concurrencyLevels.forEach(level => {
|
||||
t.comment(` ${String(level.concurrency).padEnd(11)} | ${String(level.duration + 'ms').padEnd(8)} | ${level.throughput.padEnd(10)}/s | ${level.accuracy.padEnd(8)}% | ${level.avgLatency}ms`);
|
||||
});
|
||||
t.comment(` Optimal concurrency: ${concurrentDetection.result.optimalConcurrency} (${concurrentDetection.result.maxThroughput.toFixed(2)} ops/sec)`);
|
||||
|
||||
t.comment('\nConcurrent Validation Scenarios:');
|
||||
concurrentValidation.result.scenarios.forEach(scenario => {
|
||||
t.comment(` ${scenario.name}:`);
|
||||
t.comment(` - Invoices: ${scenario.invoiceCount}, Concurrency: ${scenario.concurrency}`);
|
||||
t.comment(` - Duration: ${scenario.totalDuration}ms, Throughput: ${scenario.throughput}/sec`);
|
||||
t.comment(` - Validation rate: ${scenario.validationRate}%`);
|
||||
t.comment(` - Avg latency: ${scenario.avgLatency}ms, Max: ${scenario.maxLatency}ms`);
|
||||
t.comment(` - CPU efficiency: ${scenario.cpuEfficiency}%`);
|
||||
});
|
||||
|
||||
t.comment('\nConcurrent File Processing:');
|
||||
t.comment(' Strategy | Concur. | Duration | Processed | Throughput | Errors | Memory');
|
||||
t.comment(' ------------|---------|----------|-----------|------------|--------|-------');
|
||||
concurrentFileProcessing.result.concurrencyTests.forEach(test => {
|
||||
t.comment(` ${test.strategy.padEnd(11)} | ${String(test.concurrency).padEnd(7)} | ${String(test.duration + 'ms').padEnd(8)} | ${String(test.processed).padEnd(9)} | ${test.throughput.padEnd(10)}/s | ${test.errorRate.padEnd(6)}% | ${test.memoryIncrease}MB`);
|
||||
});
|
||||
|
||||
t.comment('\nMixed Operation Concurrency:');
|
||||
mixedOperationConcurrency.result.operations.forEach(test => {
|
||||
t.comment(` Concurrency ${test.concurrency}: ${test.throughput} ops/sec`);
|
||||
test.operationBreakdown.forEach(op => {
|
||||
t.comment(` - ${op.operation}: ${op.count} ops, avg ${op.avgTime}ms, ${op.errorRate}% errors`);
|
||||
});
|
||||
});
|
||||
|
||||
t.comment('\nOperation Contention Analysis:');
|
||||
mixedOperationConcurrency.result.contentionAnalysis.forEach(op => {
|
||||
t.comment(` ${op.operation}: avg ${op.avgDuration}ms (${op.minDuration}-${op.maxDuration}ms), variance ${op.variance}%`);
|
||||
});
|
||||
|
||||
t.comment('\nCorpus Concurrent Processing:');
|
||||
t.comment(` Total files: ${concurrentCorpusProcessing.result.totalFiles}`);
|
||||
t.comment(` Processed: ${concurrentCorpusProcessing.result.processedFiles}`);
|
||||
t.comment(` Success rate: ${concurrentCorpusProcessing.result.successRate}%`);
|
||||
t.comment(` Duration: ${(concurrentCorpusProcessing.result.totalDuration / 1000).toFixed(2)}s`);
|
||||
t.comment(` Throughput: ${concurrentCorpusProcessing.result.overallThroughput} files/sec`);
|
||||
t.comment(` Avg response time: ${concurrentCorpusProcessing.result.avgResponseTime}ms`);
|
||||
t.comment(` Peak concurrency: ${concurrentCorpusProcessing.result.peakConcurrency}`);
|
||||
|
||||
// Performance targets check
|
||||
t.comment('\n=== Performance Targets Check ===');
|
||||
const targetConcurrency = 100; // Target: >100 concurrent ops/sec
|
||||
const achievedThroughput = parseFloat(concurrentDetection.result.maxThroughput.toFixed(2));
|
||||
|
||||
t.comment(`Concurrent throughput: ${achievedThroughput} ops/sec ${achievedThroughput > targetConcurrency ? '✅' : '⚠️'} (target: >${targetConcurrency}/sec)`);
|
||||
t.comment(`Optimal concurrency: ${concurrentDetection.result.optimalConcurrency} threads`);
|
||||
|
||||
// Overall performance summary
|
||||
t.comment('\n=== Overall Performance Summary ===');
|
||||
performanceTracker.logSummary();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.start();
|
680
test/suite/einvoice_performance/test.perf-08.large-files.ts
Normal file
680
test/suite/einvoice_performance/test.perf-08.large-files.ts
Normal file
@ -0,0 +1,680 @@
|
||||
/**
|
||||
* @file test.perf-08.large-files.ts
|
||||
* @description Performance tests for large file processing
|
||||
*/
|
||||
|
||||
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-08: Large File Processing');
|
||||
|
||||
tap.test('PERF-08: Large File Processing - should handle large files efficiently', async (t) => {
|
||||
// Test 1: Large PEPPOL file processing
|
||||
const largePEPPOLProcessing = await performanceTracker.measureAsync(
|
||||
'large-peppol-processing',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/PEPPOL/**/*.xml');
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
files: [],
|
||||
memoryProfile: {
|
||||
baseline: 0,
|
||||
peak: 0,
|
||||
increments: []
|
||||
}
|
||||
};
|
||||
|
||||
// Get baseline memory
|
||||
if (global.gc) global.gc();
|
||||
const baselineMemory = process.memoryUsage();
|
||||
results.memoryProfile.baseline = baselineMemory.heapUsed / 1024 / 1024;
|
||||
|
||||
// Process PEPPOL files (known to be large)
|
||||
for (const file of files) {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const startMemory = process.memoryUsage();
|
||||
|
||||
// Read file
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
const fileSize = Buffer.byteLength(content, 'utf-8');
|
||||
|
||||
// Process file
|
||||
const format = await einvoice.detectFormat(content);
|
||||
const parseStart = Date.now();
|
||||
const invoice = await einvoice.parseInvoice(content, format || 'ubl');
|
||||
const parseEnd = Date.now();
|
||||
|
||||
const validationStart = Date.now();
|
||||
const validationResult = await einvoice.validateInvoice(invoice);
|
||||
const validationEnd = Date.now();
|
||||
|
||||
const endMemory = process.memoryUsage();
|
||||
const totalTime = Date.now() - startTime;
|
||||
|
||||
const memoryUsed = (endMemory.heapUsed - startMemory.heapUsed) / 1024 / 1024;
|
||||
if (endMemory.heapUsed > results.memoryProfile.peak) {
|
||||
results.memoryProfile.peak = endMemory.heapUsed / 1024 / 1024;
|
||||
}
|
||||
|
||||
results.files.push({
|
||||
path: file,
|
||||
sizeKB: (fileSize / 1024).toFixed(2),
|
||||
sizeMB: (fileSize / 1024 / 1024).toFixed(2),
|
||||
format,
|
||||
processingTime: totalTime,
|
||||
parseTime: parseEnd - parseStart,
|
||||
validationTime: validationEnd - validationStart,
|
||||
memoryUsedMB: memoryUsed.toFixed(2),
|
||||
throughputMBps: ((fileSize / 1024 / 1024) / (totalTime / 1000)).toFixed(2),
|
||||
itemCount: invoice.data.items?.length || 0,
|
||||
valid: validationResult.isValid
|
||||
});
|
||||
|
||||
results.memoryProfile.increments.push(memoryUsed);
|
||||
|
||||
} catch (error) {
|
||||
results.files.push({
|
||||
path: file,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 2: Synthetic large file generation and processing
|
||||
const syntheticLargeFiles = await performanceTracker.measureAsync(
|
||||
'synthetic-large-files',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
tests: [],
|
||||
scalingAnalysis: null
|
||||
};
|
||||
|
||||
// Generate invoices of increasing size
|
||||
const sizes = [
|
||||
{ items: 100, name: '100 items' },
|
||||
{ items: 500, name: '500 items' },
|
||||
{ items: 1000, name: '1K items' },
|
||||
{ items: 5000, name: '5K items' },
|
||||
{ items: 10000, name: '10K items' }
|
||||
];
|
||||
|
||||
for (const size of sizes) {
|
||||
// Generate large invoice
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `LARGE-${size.items}`,
|
||||
issueDate: '2024-02-25',
|
||||
dueDate: '2024-03-25',
|
||||
currency: 'EUR',
|
||||
seller: {
|
||||
name: 'Large File Test Seller Corporation International GmbH',
|
||||
address: 'Hauptstraße 123-125, Building A, Floor 5',
|
||||
city: 'Berlin',
|
||||
postalCode: '10115',
|
||||
country: 'DE',
|
||||
taxId: 'DE123456789',
|
||||
registrationNumber: 'HRB123456',
|
||||
email: 'invoicing@largetest.de',
|
||||
phone: '+49 30 123456789',
|
||||
bankAccount: {
|
||||
iban: 'DE89370400440532013000',
|
||||
bic: 'COBADEFFXXX',
|
||||
bankName: 'Commerzbank AG'
|
||||
}
|
||||
},
|
||||
buyer: {
|
||||
name: 'Large File Test Buyer Enterprises Ltd.',
|
||||
address: '456 Commerce Boulevard, Suite 789',
|
||||
city: 'Munich',
|
||||
postalCode: '80331',
|
||||
country: 'DE',
|
||||
taxId: 'DE987654321',
|
||||
registrationNumber: 'HRB654321',
|
||||
email: 'ap@largebuyer.de',
|
||||
phone: '+49 89 987654321'
|
||||
},
|
||||
items: Array.from({ length: size.items }, (_, i) => ({
|
||||
itemId: `ITEM-${String(i + 1).padStart(6, '0')}`,
|
||||
description: `Product Item Number ${i + 1} - Detailed description with technical specifications, compliance information, country of origin, weight, dimensions, and special handling instructions. This is a very detailed description to simulate real-world invoice data with comprehensive product information.`,
|
||||
quantity: Math.floor(Math.random() * 100) + 1,
|
||||
unitPrice: Math.random() * 1000,
|
||||
vatRate: [0, 7, 19][Math.floor(Math.random() * 3)],
|
||||
lineTotal: 0,
|
||||
additionalInfo: {
|
||||
weight: `${(Math.random() * 50).toFixed(2)}kg`,
|
||||
dimensions: `${Math.floor(Math.random() * 100)}x${Math.floor(Math.random() * 100)}x${Math.floor(Math.random() * 100)}cm`,
|
||||
countryOfOrigin: ['DE', 'FR', 'IT', 'CN', 'US'][Math.floor(Math.random() * 5)],
|
||||
customsCode: `${Math.floor(Math.random() * 9000000000) + 1000000000}`,
|
||||
serialNumber: `SN-${Date.now()}-${i}`,
|
||||
batchNumber: `BATCH-${Math.floor(i / 100)}`
|
||||
}
|
||||
})),
|
||||
totals: { netAmount: 0, vatAmount: 0, grossAmount: 0 },
|
||||
notes: 'This is a large invoice generated for performance testing purposes. ' +
|
||||
'It contains a significant number of line items to test the system\'s ability ' +
|
||||
'to handle large documents efficiently.'
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate totals
|
||||
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;
|
||||
|
||||
// Measure processing
|
||||
if (global.gc) global.gc();
|
||||
const startMemory = process.memoryUsage();
|
||||
const startTime = Date.now();
|
||||
|
||||
// Generate XML
|
||||
const xmlStart = Date.now();
|
||||
const xml = await einvoice.generateXML(invoice);
|
||||
const xmlEnd = Date.now();
|
||||
const xmlSize = Buffer.byteLength(xml, 'utf-8');
|
||||
|
||||
// Parse back
|
||||
const parseStart = Date.now();
|
||||
const parsed = await einvoice.parseInvoice(xml, 'ubl');
|
||||
const parseEnd = Date.now();
|
||||
|
||||
// Validate
|
||||
const validateStart = Date.now();
|
||||
const validation = await einvoice.validateInvoice(parsed);
|
||||
const validateEnd = Date.now();
|
||||
|
||||
// Convert
|
||||
const convertStart = Date.now();
|
||||
const converted = await einvoice.convertFormat(parsed, 'cii');
|
||||
const convertEnd = Date.now();
|
||||
|
||||
const endTime = Date.now();
|
||||
const endMemory = process.memoryUsage();
|
||||
|
||||
results.tests.push({
|
||||
size: size.name,
|
||||
items: size.items,
|
||||
xmlSizeMB: (xmlSize / 1024 / 1024).toFixed(2),
|
||||
totalTime: endTime - startTime,
|
||||
xmlGeneration: xmlEnd - xmlStart,
|
||||
parsing: parseEnd - parseStart,
|
||||
validation: validateEnd - validateStart,
|
||||
conversion: convertEnd - convertStart,
|
||||
memoryUsedMB: ((endMemory.heapUsed - startMemory.heapUsed) / 1024 / 1024).toFixed(2),
|
||||
memoryPerItemKB: ((endMemory.heapUsed - startMemory.heapUsed) / 1024 / size.items).toFixed(2),
|
||||
throughputMBps: ((xmlSize / 1024 / 1024) / ((endTime - startTime) / 1000)).toFixed(2),
|
||||
valid: validation.isValid
|
||||
});
|
||||
}
|
||||
|
||||
// Analyze scaling
|
||||
if (results.tests.length >= 3) {
|
||||
const points = results.tests.map(t => ({
|
||||
x: t.items,
|
||||
y: t.totalTime
|
||||
}));
|
||||
|
||||
// Simple linear regression
|
||||
const n = points.length;
|
||||
const sumX = points.reduce((sum, p) => sum + p.x, 0);
|
||||
const sumY = points.reduce((sum, p) => sum + p.y, 0);
|
||||
const sumXY = points.reduce((sum, p) => sum + p.x * p.y, 0);
|
||||
const sumX2 = points.reduce((sum, p) => sum + p.x * p.x, 0);
|
||||
|
||||
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
|
||||
const intercept = (sumY - slope * sumX) / n;
|
||||
|
||||
results.scalingAnalysis = {
|
||||
type: slope < 0.5 ? 'Sub-linear' : slope <= 1.5 ? 'Linear' : 'Super-linear',
|
||||
formula: `Time(ms) = ${slope.toFixed(3)} * items + ${intercept.toFixed(2)}`,
|
||||
msPerItem: slope.toFixed(3)
|
||||
};
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 3: Memory-efficient large file streaming
|
||||
const streamingLargeFiles = await performanceTracker.measureAsync(
|
||||
'streaming-large-files',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
streamingSupported: false,
|
||||
chunkProcessing: [],
|
||||
memoryEfficiency: null
|
||||
};
|
||||
|
||||
// Simulate large file processing in chunks
|
||||
const totalItems = 10000;
|
||||
const chunkSizes = [100, 500, 1000, 2000];
|
||||
|
||||
for (const chunkSize of chunkSizes) {
|
||||
const chunks = Math.ceil(totalItems / chunkSize);
|
||||
const startTime = Date.now();
|
||||
const startMemory = process.memoryUsage();
|
||||
let peakMemory = startMemory.heapUsed;
|
||||
|
||||
// Process in chunks
|
||||
const chunkResults = [];
|
||||
|
||||
for (let chunk = 0; chunk < chunks; chunk++) {
|
||||
const startItem = chunk * chunkSize;
|
||||
const endItem = Math.min(startItem + chunkSize, totalItems);
|
||||
|
||||
// Create chunk invoice
|
||||
const chunkInvoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `CHUNK-${chunk}`,
|
||||
issueDate: '2024-02-25',
|
||||
seller: { name: 'Chunk Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: 'Chunk Buyer', address: 'Address', country: 'US', taxId: 'US456' },
|
||||
items: Array.from({ length: endItem - startItem }, (_, i) => ({
|
||||
description: `Chunk ${chunk} Item ${i + 1}`,
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 19,
|
||||
lineTotal: 100
|
||||
})),
|
||||
totals: {
|
||||
netAmount: (endItem - startItem) * 100,
|
||||
vatAmount: (endItem - startItem) * 19,
|
||||
grossAmount: (endItem - startItem) * 119
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Process chunk
|
||||
const chunkStart = Date.now();
|
||||
await einvoice.validateInvoice(chunkInvoice);
|
||||
const chunkEnd = Date.now();
|
||||
|
||||
chunkResults.push({
|
||||
chunk,
|
||||
items: endItem - startItem,
|
||||
duration: chunkEnd - chunkStart
|
||||
});
|
||||
|
||||
// Track peak memory
|
||||
const currentMemory = process.memoryUsage();
|
||||
if (currentMemory.heapUsed > peakMemory) {
|
||||
peakMemory = currentMemory.heapUsed;
|
||||
}
|
||||
|
||||
// Simulate cleanup between chunks
|
||||
if (global.gc) global.gc();
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - startTime;
|
||||
const memoryIncrease = (peakMemory - startMemory.heapUsed) / 1024 / 1024;
|
||||
|
||||
results.chunkProcessing.push({
|
||||
chunkSize,
|
||||
chunks,
|
||||
totalItems,
|
||||
totalDuration,
|
||||
avgChunkTime: chunkResults.reduce((sum, r) => sum + r.duration, 0) / chunkResults.length,
|
||||
throughput: (totalItems / (totalDuration / 1000)).toFixed(2),
|
||||
peakMemoryMB: (peakMemory / 1024 / 1024).toFixed(2),
|
||||
memoryIncreaseMB: memoryIncrease.toFixed(2),
|
||||
memoryPerItemKB: ((memoryIncrease * 1024) / totalItems).toFixed(3)
|
||||
});
|
||||
}
|
||||
|
||||
// Analyze memory efficiency
|
||||
if (results.chunkProcessing.length > 0) {
|
||||
const smallChunk = results.chunkProcessing[0];
|
||||
const largeChunk = results.chunkProcessing[results.chunkProcessing.length - 1];
|
||||
|
||||
results.memoryEfficiency = {
|
||||
smallChunkMemory: smallChunk.memoryIncreaseMB,
|
||||
largeChunkMemory: largeChunk.memoryIncreaseMB,
|
||||
memoryScaling: (parseFloat(largeChunk.memoryIncreaseMB) / parseFloat(smallChunk.memoryIncreaseMB)).toFixed(2),
|
||||
recommendation: parseFloat(largeChunk.memoryIncreaseMB) < parseFloat(smallChunk.memoryIncreaseMB) * 2 ?
|
||||
'Use larger chunks for better memory efficiency' :
|
||||
'Use smaller chunks to reduce memory usage'
|
||||
};
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 4: Corpus large file analysis
|
||||
const corpusLargeFiles = await performanceTracker.measureAsync(
|
||||
'corpus-large-file-analysis',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
totalFiles: 0,
|
||||
largeFiles: [],
|
||||
sizeDistribution: {
|
||||
tiny: { count: 0, maxSize: 10 * 1024 }, // < 10KB
|
||||
small: { count: 0, maxSize: 100 * 1024 }, // < 100KB
|
||||
medium: { count: 0, maxSize: 1024 * 1024 }, // < 1MB
|
||||
large: { count: 0, maxSize: 10 * 1024 * 1024 }, // < 10MB
|
||||
huge: { count: 0, maxSize: Infinity } // >= 10MB
|
||||
},
|
||||
processingStats: {
|
||||
avgTimePerKB: 0,
|
||||
avgMemoryPerKB: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Analyze all files
|
||||
const fileSizes = [];
|
||||
const processingMetrics = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const stats = await plugins.fs.stat(file);
|
||||
const fileSize = stats.size;
|
||||
results.totalFiles++;
|
||||
|
||||
// Categorize by size
|
||||
if (fileSize < results.sizeDistribution.tiny.maxSize) {
|
||||
results.sizeDistribution.tiny.count++;
|
||||
} else if (fileSize < results.sizeDistribution.small.maxSize) {
|
||||
results.sizeDistribution.small.count++;
|
||||
} else if (fileSize < results.sizeDistribution.medium.maxSize) {
|
||||
results.sizeDistribution.medium.count++;
|
||||
} else if (fileSize < results.sizeDistribution.large.maxSize) {
|
||||
results.sizeDistribution.large.count++;
|
||||
} else {
|
||||
results.sizeDistribution.huge.count++;
|
||||
}
|
||||
|
||||
// Process large files
|
||||
if (fileSize > 100 * 1024) { // Process files > 100KB
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
|
||||
const startTime = Date.now();
|
||||
const startMemory = process.memoryUsage();
|
||||
|
||||
const format = await einvoice.detectFormat(content);
|
||||
if (format && format !== 'unknown') {
|
||||
const invoice = await einvoice.parseInvoice(content, format);
|
||||
await einvoice.validateInvoice(invoice);
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
const endMemory = process.memoryUsage();
|
||||
|
||||
const processingTime = endTime - startTime;
|
||||
const memoryUsed = (endMemory.heapUsed - startMemory.heapUsed) / 1024; // KB
|
||||
|
||||
results.largeFiles.push({
|
||||
path: file,
|
||||
sizeKB: (fileSize / 1024).toFixed(2),
|
||||
format,
|
||||
processingTime,
|
||||
memoryUsedKB: memoryUsed.toFixed(2),
|
||||
timePerKB: (processingTime / (fileSize / 1024)).toFixed(3),
|
||||
throughputKBps: ((fileSize / 1024) / (processingTime / 1000)).toFixed(2)
|
||||
});
|
||||
|
||||
processingMetrics.push({
|
||||
size: fileSize,
|
||||
time: processingTime,
|
||||
memory: memoryUsed
|
||||
});
|
||||
}
|
||||
|
||||
fileSizes.push(fileSize);
|
||||
|
||||
} catch (error) {
|
||||
// Skip files that can't be processed
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
if (processingMetrics.length > 0) {
|
||||
const totalSize = processingMetrics.reduce((sum, m) => sum + m.size, 0);
|
||||
const totalTime = processingMetrics.reduce((sum, m) => sum + m.time, 0);
|
||||
const totalMemory = processingMetrics.reduce((sum, m) => sum + m.memory, 0);
|
||||
|
||||
results.processingStats.avgTimePerKB = (totalTime / (totalSize / 1024)).toFixed(3);
|
||||
results.processingStats.avgMemoryPerKB = (totalMemory / (totalSize / 1024)).toFixed(3);
|
||||
}
|
||||
|
||||
// Sort large files by size
|
||||
results.largeFiles.sort((a, b) => parseFloat(b.sizeKB) - parseFloat(a.sizeKB));
|
||||
|
||||
return {
|
||||
...results,
|
||||
largeFiles: results.largeFiles.slice(0, 10), // Top 10 largest
|
||||
avgFileSizeKB: fileSizes.length > 0 ?
|
||||
(fileSizes.reduce((a, b) => a + b, 0) / fileSizes.length / 1024).toFixed(2) : 0
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Test 5: Stress test with extreme sizes
|
||||
const extremeSizeStressTest = await performanceTracker.measureAsync(
|
||||
'extreme-size-stress-test',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
tests: [],
|
||||
limits: {
|
||||
maxItemsProcessed: 0,
|
||||
maxSizeProcessedMB: 0,
|
||||
failurePoint: null
|
||||
}
|
||||
};
|
||||
|
||||
// Test extreme scenarios
|
||||
const extremeScenarios = [
|
||||
{
|
||||
name: 'Wide invoice (many items)',
|
||||
generator: (count: number) => ({
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `EXTREME-WIDE-${count}`,
|
||||
issueDate: '2024-02-25',
|
||||
seller: { name: 'Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: 'Buyer', address: 'Address', country: 'US', taxId: 'US456' },
|
||||
items: Array.from({ length: count }, (_, i) => ({
|
||||
description: `Item ${i + 1}`,
|
||||
quantity: 1,
|
||||
unitPrice: 10,
|
||||
vatRate: 10,
|
||||
lineTotal: 10
|
||||
})),
|
||||
totals: { netAmount: count * 10, vatAmount: count, grossAmount: count * 11 }
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'Deep invoice (long descriptions)',
|
||||
generator: (size: number) => ({
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `EXTREME-DEEP-${size}`,
|
||||
issueDate: '2024-02-25',
|
||||
seller: { name: 'Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: 'Buyer', address: 'Address', country: 'US', taxId: 'US456' },
|
||||
items: [{
|
||||
description: 'A'.repeat(size * 1024), // Size in KB
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 10,
|
||||
lineTotal: 100
|
||||
}],
|
||||
totals: { netAmount: 100, vatAmount: 10, grossAmount: 110 }
|
||||
}
|
||||
})
|
||||
}
|
||||
];
|
||||
|
||||
// Test each scenario
|
||||
for (const scenario of extremeScenarios) {
|
||||
const testResults = {
|
||||
scenario: scenario.name,
|
||||
tests: []
|
||||
};
|
||||
|
||||
// Test increasing sizes
|
||||
const sizes = scenario.name.includes('Wide') ?
|
||||
[1000, 5000, 10000, 20000, 50000] :
|
||||
[100, 500, 1000, 2000, 5000]; // KB
|
||||
|
||||
for (const size of sizes) {
|
||||
try {
|
||||
const invoice = scenario.generator(size);
|
||||
|
||||
const startTime = Date.now();
|
||||
const startMemory = process.memoryUsage();
|
||||
|
||||
// Try to process
|
||||
const xml = await einvoice.generateXML(invoice);
|
||||
const xmlSize = Buffer.byteLength(xml, 'utf-8') / 1024 / 1024; // MB
|
||||
|
||||
const parsed = await einvoice.parseInvoice(xml, invoice.format);
|
||||
await einvoice.validateInvoice(parsed);
|
||||
|
||||
const endTime = Date.now();
|
||||
const endMemory = process.memoryUsage();
|
||||
|
||||
testResults.tests.push({
|
||||
size: scenario.name.includes('Wide') ? `${size} items` : `${size}KB text`,
|
||||
success: true,
|
||||
time: endTime - startTime,
|
||||
memoryMB: ((endMemory.heapUsed - startMemory.heapUsed) / 1024 / 1024).toFixed(2),
|
||||
xmlSizeMB: xmlSize.toFixed(2)
|
||||
});
|
||||
|
||||
// Update limits
|
||||
if (scenario.name.includes('Wide') && size > results.limits.maxItemsProcessed) {
|
||||
results.limits.maxItemsProcessed = size;
|
||||
}
|
||||
if (xmlSize > results.limits.maxSizeProcessedMB) {
|
||||
results.limits.maxSizeProcessedMB = xmlSize;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
testResults.tests.push({
|
||||
size: scenario.name.includes('Wide') ? `${size} items` : `${size}KB text`,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
if (!results.limits.failurePoint) {
|
||||
results.limits.failurePoint = {
|
||||
scenario: scenario.name,
|
||||
size,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
break; // Stop testing larger sizes after failure
|
||||
}
|
||||
}
|
||||
|
||||
results.tests.push(testResults);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Summary
|
||||
t.comment('\n=== PERF-08: Large File Processing Test Summary ===');
|
||||
|
||||
if (largePEPPOLProcessing.result.files.length > 0) {
|
||||
t.comment('\nLarge PEPPOL File Processing:');
|
||||
largePEPPOLProcessing.result.files.forEach(file => {
|
||||
if (!file.error) {
|
||||
t.comment(` ${file.path.split('/').pop()}:`);
|
||||
t.comment(` - Size: ${file.sizeMB}MB, Items: ${file.itemCount}`);
|
||||
t.comment(` - Processing: ${file.processingTime}ms (parse: ${file.parseTime}ms, validate: ${file.validationTime}ms)`);
|
||||
t.comment(` - Throughput: ${file.throughputMBps}MB/s`);
|
||||
t.comment(` - Memory used: ${file.memoryUsedMB}MB`);
|
||||
}
|
||||
});
|
||||
t.comment(` Peak memory: ${largePEPPOLProcessing.result.memoryProfile.peak.toFixed(2)}MB`);
|
||||
}
|
||||
|
||||
t.comment('\nSynthetic Large File Scaling:');
|
||||
t.comment(' Size | XML Size | Total Time | Parse | Validate | Convert | Memory | Throughput');
|
||||
t.comment(' ----------|----------|------------|--------|----------|---------|--------|----------');
|
||||
syntheticLargeFiles.result.tests.forEach(test => {
|
||||
t.comment(` ${test.size.padEnd(9)} | ${test.xmlSizeMB.padEnd(8)}MB | ${String(test.totalTime + 'ms').padEnd(10)} | ${String(test.parsing + 'ms').padEnd(6)} | ${String(test.validation + 'ms').padEnd(8)} | ${String(test.conversion + 'ms').padEnd(7)} | ${test.memoryUsedMB.padEnd(6)}MB | ${test.throughputMBps}MB/s`);
|
||||
});
|
||||
if (syntheticLargeFiles.result.scalingAnalysis) {
|
||||
t.comment(` Scaling: ${syntheticLargeFiles.result.scalingAnalysis.type}`);
|
||||
t.comment(` Formula: ${syntheticLargeFiles.result.scalingAnalysis.formula}`);
|
||||
}
|
||||
|
||||
t.comment('\nChunked Processing Efficiency:');
|
||||
t.comment(' Chunk Size | Chunks | Duration | Throughput | Peak Memory | Memory/Item');
|
||||
t.comment(' -----------|--------|----------|------------|-------------|------------');
|
||||
streamingLargeFiles.result.chunkProcessing.forEach(chunk => {
|
||||
t.comment(` ${String(chunk.chunkSize).padEnd(10)} | ${String(chunk.chunks).padEnd(6)} | ${String(chunk.totalDuration + 'ms').padEnd(8)} | ${chunk.throughput.padEnd(10)}/s | ${chunk.peakMemoryMB.padEnd(11)}MB | ${chunk.memoryPerItemKB}KB`);
|
||||
});
|
||||
if (streamingLargeFiles.result.memoryEfficiency) {
|
||||
t.comment(` Recommendation: ${streamingLargeFiles.result.memoryEfficiency.recommendation}`);
|
||||
}
|
||||
|
||||
t.comment('\nCorpus Large File Analysis:');
|
||||
t.comment(` Total files: ${corpusLargeFiles.result.totalFiles}`);
|
||||
t.comment(` Size distribution:`);
|
||||
Object.entries(corpusLargeFiles.result.sizeDistribution).forEach(([size, data]: [string, any]) => {
|
||||
t.comment(` - ${size}: ${data.count} files`);
|
||||
});
|
||||
t.comment(` Largest processed files:`);
|
||||
corpusLargeFiles.result.largeFiles.slice(0, 5).forEach(file => {
|
||||
t.comment(` - ${file.path.split('/').pop()}: ${file.sizeKB}KB, ${file.processingTime}ms, ${file.throughputKBps}KB/s`);
|
||||
});
|
||||
t.comment(` Average processing: ${corpusLargeFiles.result.processingStats.avgTimePerKB}ms/KB`);
|
||||
|
||||
t.comment('\nExtreme Size Stress Test:');
|
||||
extremeSizeStressTest.result.tests.forEach(scenario => {
|
||||
t.comment(` ${scenario.scenario}:`);
|
||||
scenario.tests.forEach(test => {
|
||||
t.comment(` - ${test.size}: ${test.success ? `✅ ${test.time}ms, ${test.xmlSizeMB}MB XML` : `❌ ${test.error}`}`);
|
||||
});
|
||||
});
|
||||
t.comment(` Limits:`);
|
||||
t.comment(` - Max items processed: ${extremeSizeStressTest.result.limits.maxItemsProcessed}`);
|
||||
t.comment(` - Max size processed: ${extremeSizeStressTest.result.limits.maxSizeProcessedMB.toFixed(2)}MB`);
|
||||
if (extremeSizeStressTest.result.limits.failurePoint) {
|
||||
t.comment(` - Failure point: ${extremeSizeStressTest.result.limits.failurePoint.scenario} at ${extremeSizeStressTest.result.limits.failurePoint.size}`);
|
||||
}
|
||||
|
||||
// Performance targets check
|
||||
t.comment('\n=== Performance Targets Check ===');
|
||||
const largeFileThroughput = syntheticLargeFiles.result.tests.length > 0 ?
|
||||
parseFloat(syntheticLargeFiles.result.tests[syntheticLargeFiles.result.tests.length - 1].throughputMBps) : 0;
|
||||
const targetThroughput = 1; // Target: >1MB/s for large files
|
||||
|
||||
t.comment(`Large file throughput: ${largeFileThroughput}MB/s ${largeFileThroughput > targetThroughput ? '✅' : '⚠️'} (target: >${targetThroughput}MB/s)`);
|
||||
|
||||
// Overall performance summary
|
||||
t.comment('\n=== Overall Performance Summary ===');
|
||||
performanceTracker.logSummary();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.start();
|
813
test/suite/einvoice_performance/test.perf-09.streaming.ts
Normal file
813
test/suite/einvoice_performance/test.perf-09.streaming.ts
Normal file
@ -0,0 +1,813 @@
|
||||
/**
|
||||
* @file test.perf-09.streaming.ts
|
||||
* @description Performance tests for streaming operations
|
||||
*/
|
||||
|
||||
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';
|
||||
import { Readable, Writable, Transform } from 'stream';
|
||||
|
||||
const corpusLoader = new CorpusLoader();
|
||||
const performanceTracker = new PerformanceTracker('PERF-09: Streaming Performance');
|
||||
|
||||
tap.test('PERF-09: Streaming Performance - should handle streaming operations efficiently', async (t) => {
|
||||
// Test 1: Streaming XML parsing
|
||||
const streamingXMLParsing = await performanceTracker.measureAsync(
|
||||
'streaming-xml-parsing',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
tests: [],
|
||||
memoryEfficiency: null
|
||||
};
|
||||
|
||||
// Create test XML streams of different sizes
|
||||
const createXMLStream = (itemCount: number): Readable => {
|
||||
let currentItem = 0;
|
||||
let headerSent = false;
|
||||
let itemsSent = false;
|
||||
|
||||
return new Readable({
|
||||
read() {
|
||||
if (!headerSent) {
|
||||
this.push(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>STREAM-${itemCount}</ID>
|
||||
<IssueDate>2024-03-01</IssueDate>
|
||||
<AccountingSupplierParty>
|
||||
<Party>
|
||||
<PartyName><Name>Streaming Supplier</Name></PartyName>
|
||||
</Party>
|
||||
</AccountingSupplierParty>
|
||||
<AccountingCustomerParty>
|
||||
<Party>
|
||||
<PartyName><Name>Streaming Customer</Name></PartyName>
|
||||
</Party>
|
||||
</AccountingCustomerParty>
|
||||
<InvoiceLine>`);
|
||||
headerSent = true;
|
||||
} else if (currentItem < itemCount) {
|
||||
// Send items in chunks
|
||||
const chunkSize = Math.min(10, itemCount - currentItem);
|
||||
let chunk = '';
|
||||
|
||||
for (let i = 0; i < chunkSize; i++) {
|
||||
chunk += `
|
||||
<InvoiceLine>
|
||||
<ID>${currentItem + i + 1}</ID>
|
||||
<InvoicedQuantity>1</InvoicedQuantity>
|
||||
<LineExtensionAmount>100.00</LineExtensionAmount>
|
||||
<Item>
|
||||
<Description>Streaming Item ${currentItem + i + 1}</Description>
|
||||
</Item>
|
||||
</InvoiceLine>`;
|
||||
}
|
||||
|
||||
this.push(chunk);
|
||||
currentItem += chunkSize;
|
||||
|
||||
// Simulate streaming delay
|
||||
setTimeout(() => this.read(), 1);
|
||||
} else if (!itemsSent) {
|
||||
this.push(`
|
||||
</InvoiceLine>
|
||||
</Invoice>`);
|
||||
itemsSent = true;
|
||||
} else {
|
||||
this.push(null); // End stream
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Test different stream sizes
|
||||
const streamSizes = [
|
||||
{ items: 10, name: 'Small stream' },
|
||||
{ items: 100, name: 'Medium stream' },
|
||||
{ items: 1000, name: 'Large stream' },
|
||||
{ items: 5000, name: 'Very large stream' }
|
||||
];
|
||||
|
||||
for (const size of streamSizes) {
|
||||
const startTime = Date.now();
|
||||
const startMemory = process.memoryUsage();
|
||||
const memorySnapshots = [];
|
||||
|
||||
// Create monitoring interval
|
||||
const monitorInterval = setInterval(() => {
|
||||
memorySnapshots.push(process.memoryUsage().heapUsed / 1024 / 1024);
|
||||
}, 100);
|
||||
|
||||
try {
|
||||
// Simulate streaming parsing
|
||||
const stream = createXMLStream(size.items);
|
||||
const chunks = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.on('data', (chunk) => {
|
||||
chunks.push(chunk);
|
||||
totalBytes += chunk.length;
|
||||
});
|
||||
|
||||
stream.on('end', async () => {
|
||||
clearInterval(monitorInterval);
|
||||
|
||||
// Parse accumulated XML
|
||||
const xml = chunks.join('');
|
||||
const format = await einvoice.detectFormat(xml);
|
||||
const invoice = await einvoice.parseInvoice(xml, format || 'ubl');
|
||||
|
||||
const endTime = Date.now();
|
||||
const endMemory = process.memoryUsage();
|
||||
|
||||
results.tests.push({
|
||||
size: size.name,
|
||||
items: size.items,
|
||||
totalBytes: (totalBytes / 1024).toFixed(2),
|
||||
duration: endTime - startTime,
|
||||
memoryUsed: ((endMemory.heapUsed - startMemory.heapUsed) / 1024 / 1024).toFixed(2),
|
||||
peakMemory: Math.max(...memorySnapshots).toFixed(2),
|
||||
avgMemory: (memorySnapshots.reduce((a, b) => a + b, 0) / memorySnapshots.length).toFixed(2),
|
||||
throughput: ((totalBytes / 1024) / ((endTime - startTime) / 1000)).toFixed(2),
|
||||
itemsProcessed: invoice.data.items?.length || 0
|
||||
});
|
||||
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
stream.on('error', reject);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
clearInterval(monitorInterval);
|
||||
results.tests.push({
|
||||
size: size.name,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze memory efficiency
|
||||
if (results.tests.length >= 2) {
|
||||
const small = results.tests[0];
|
||||
const large = results.tests[results.tests.length - 1];
|
||||
|
||||
if (!small.error && !large.error) {
|
||||
results.memoryEfficiency = {
|
||||
smallStreamMemory: small.memoryUsed,
|
||||
largeStreamMemory: large.memoryUsed,
|
||||
memoryScaling: (parseFloat(large.memoryUsed) / parseFloat(small.memoryUsed)).toFixed(2),
|
||||
itemScaling: large.items / small.items,
|
||||
efficient: parseFloat(large.memoryUsed) < parseFloat(small.memoryUsed) * (large.items / small.items)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 2: Stream transformation pipeline
|
||||
const streamTransformation = await performanceTracker.measureAsync(
|
||||
'stream-transformation-pipeline',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
pipelines: [],
|
||||
transformationStats: null
|
||||
};
|
||||
|
||||
// Create transformation streams
|
||||
class FormatDetectionStream extends Transform {
|
||||
constructor(private einvoice: EInvoice) {
|
||||
super({ objectMode: true });
|
||||
}
|
||||
|
||||
async _transform(chunk: any, encoding: string, callback: Function) {
|
||||
try {
|
||||
const format = await this.einvoice.detectFormat(chunk.content);
|
||||
this.push({ ...chunk, format });
|
||||
callback();
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ValidationStream extends Transform {
|
||||
constructor(private einvoice: EInvoice) {
|
||||
super({ objectMode: true });
|
||||
}
|
||||
|
||||
async _transform(chunk: any, encoding: string, callback: Function) {
|
||||
try {
|
||||
if (chunk.format && chunk.format !== 'unknown') {
|
||||
const invoice = await this.einvoice.parseInvoice(chunk.content, chunk.format);
|
||||
const validation = await this.einvoice.validateInvoice(invoice);
|
||||
this.push({ ...chunk, valid: validation.isValid, errors: validation.errors?.length || 0 });
|
||||
} else {
|
||||
this.push({ ...chunk, valid: false, errors: -1 });
|
||||
}
|
||||
callback();
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test different pipeline configurations
|
||||
const pipelineConfigs = [
|
||||
{
|
||||
name: 'Simple pipeline',
|
||||
batchSize: 10,
|
||||
stages: ['detect', 'validate']
|
||||
},
|
||||
{
|
||||
name: 'Parallel pipeline',
|
||||
batchSize: 50,
|
||||
stages: ['detect', 'validate'],
|
||||
parallel: true
|
||||
},
|
||||
{
|
||||
name: 'Complex pipeline',
|
||||
batchSize: 100,
|
||||
stages: ['detect', 'parse', 'validate', 'convert']
|
||||
}
|
||||
];
|
||||
|
||||
// Create test data
|
||||
const testInvoices = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i,
|
||||
content: `<?xml version="1.0"?>
|
||||
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
||||
<ID>PIPELINE-${i}</ID>
|
||||
<IssueDate>2024-03-01</IssueDate>
|
||||
<AccountingSupplierParty><Party><PartyName><Name>Supplier ${i}</Name></PartyName></Party></AccountingSupplierParty>
|
||||
<AccountingCustomerParty><Party><PartyName><Name>Customer ${i}</Name></PartyName></Party></AccountingCustomerParty>
|
||||
<InvoiceLine>
|
||||
<ID>1</ID>
|
||||
<InvoicedQuantity>1</InvoicedQuantity>
|
||||
<LineExtensionAmount>${100 + i}</LineExtensionAmount>
|
||||
</InvoiceLine>
|
||||
</Invoice>`
|
||||
}));
|
||||
|
||||
for (const config of pipelineConfigs) {
|
||||
const startTime = Date.now();
|
||||
const processedItems = [];
|
||||
|
||||
try {
|
||||
// Create pipeline
|
||||
const inputStream = new Readable({
|
||||
objectMode: true,
|
||||
read() {
|
||||
const item = testInvoices.shift();
|
||||
if (item) {
|
||||
this.push(item);
|
||||
} else {
|
||||
this.push(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const outputStream = new Writable({
|
||||
objectMode: true,
|
||||
write(chunk, encoding, callback) {
|
||||
processedItems.push(chunk);
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
// Build pipeline
|
||||
let pipeline = inputStream;
|
||||
|
||||
if (config.stages.includes('detect')) {
|
||||
pipeline = pipeline.pipe(new FormatDetectionStream(einvoice));
|
||||
}
|
||||
|
||||
if (config.stages.includes('validate')) {
|
||||
pipeline = pipeline.pipe(new ValidationStream(einvoice));
|
||||
}
|
||||
|
||||
// Process
|
||||
await new Promise((resolve, reject) => {
|
||||
pipeline.pipe(outputStream)
|
||||
.on('finish', resolve)
|
||||
.on('error', reject);
|
||||
});
|
||||
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
results.pipelines.push({
|
||||
name: config.name,
|
||||
batchSize: config.batchSize,
|
||||
stages: config.stages.length,
|
||||
itemsProcessed: processedItems.length,
|
||||
duration,
|
||||
throughput: (processedItems.length / (duration / 1000)).toFixed(2),
|
||||
avgLatency: (duration / processedItems.length).toFixed(2),
|
||||
validItems: processedItems.filter(i => i.valid).length,
|
||||
errorItems: processedItems.filter(i => !i.valid).length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
results.pipelines.push({
|
||||
name: config.name,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze transformation efficiency
|
||||
if (results.pipelines.length > 0) {
|
||||
const validPipelines = results.pipelines.filter(p => !p.error);
|
||||
if (validPipelines.length > 0) {
|
||||
const avgThroughput = validPipelines.reduce((sum, p) => sum + parseFloat(p.throughput), 0) / validPipelines.length;
|
||||
const bestPipeline = validPipelines.reduce((best, p) =>
|
||||
parseFloat(p.throughput) > parseFloat(best.throughput) ? p : best
|
||||
);
|
||||
|
||||
results.transformationStats = {
|
||||
avgThroughput: avgThroughput.toFixed(2),
|
||||
bestPipeline: bestPipeline.name,
|
||||
bestThroughput: bestPipeline.throughput
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 3: Backpressure handling
|
||||
const backpressureHandling = await performanceTracker.measureAsync(
|
||||
'backpressure-handling',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
scenarios: [],
|
||||
backpressureStats: null
|
||||
};
|
||||
|
||||
// Test scenarios with different processing speeds
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'Fast producer, slow consumer',
|
||||
producerDelay: 1,
|
||||
consumerDelay: 10,
|
||||
bufferSize: 100
|
||||
},
|
||||
{
|
||||
name: 'Slow producer, fast consumer',
|
||||
producerDelay: 10,
|
||||
consumerDelay: 1,
|
||||
bufferSize: 100
|
||||
},
|
||||
{
|
||||
name: 'Balanced pipeline',
|
||||
producerDelay: 5,
|
||||
consumerDelay: 5,
|
||||
bufferSize: 100
|
||||
},
|
||||
{
|
||||
name: 'High volume burst',
|
||||
producerDelay: 0,
|
||||
consumerDelay: 5,
|
||||
bufferSize: 1000
|
||||
}
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const startTime = Date.now();
|
||||
const metrics = {
|
||||
produced: 0,
|
||||
consumed: 0,
|
||||
buffered: 0,
|
||||
maxBuffered: 0,
|
||||
backpressureEvents: 0
|
||||
};
|
||||
|
||||
try {
|
||||
// Create producer stream
|
||||
const producer = new Readable({
|
||||
objectMode: true,
|
||||
highWaterMark: scenario.bufferSize,
|
||||
read() {
|
||||
if (metrics.produced < 100) {
|
||||
setTimeout(() => {
|
||||
this.push({
|
||||
id: metrics.produced++,
|
||||
content: `<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ID>BP-${metrics.produced}</ID></Invoice>`
|
||||
});
|
||||
|
||||
metrics.buffered = metrics.produced - metrics.consumed;
|
||||
if (metrics.buffered > metrics.maxBuffered) {
|
||||
metrics.maxBuffered = metrics.buffered;
|
||||
}
|
||||
}, scenario.producerDelay);
|
||||
} else {
|
||||
this.push(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create consumer stream with processing
|
||||
const consumer = new Writable({
|
||||
objectMode: true,
|
||||
highWaterMark: scenario.bufferSize,
|
||||
async write(chunk, encoding, callback) {
|
||||
// Simulate processing
|
||||
await new Promise(resolve => setTimeout(resolve, scenario.consumerDelay));
|
||||
|
||||
// Process invoice
|
||||
const format = await einvoice.detectFormat(chunk.content);
|
||||
|
||||
metrics.consumed++;
|
||||
metrics.buffered = metrics.produced - metrics.consumed;
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
// Monitor backpressure
|
||||
producer.on('pause', () => metrics.backpressureEvents++);
|
||||
|
||||
// Process
|
||||
await new Promise((resolve, reject) => {
|
||||
producer.pipe(consumer)
|
||||
.on('finish', resolve)
|
||||
.on('error', reject);
|
||||
});
|
||||
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
results.scenarios.push({
|
||||
name: scenario.name,
|
||||
duration,
|
||||
produced: metrics.produced,
|
||||
consumed: metrics.consumed,
|
||||
maxBuffered: metrics.maxBuffered,
|
||||
backpressureEvents: metrics.backpressureEvents,
|
||||
throughput: (metrics.consumed / (duration / 1000)).toFixed(2),
|
||||
efficiency: ((metrics.consumed / metrics.produced) * 100).toFixed(2),
|
||||
avgBufferUtilization: ((metrics.maxBuffered / scenario.bufferSize) * 100).toFixed(2)
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
results.scenarios.push({
|
||||
name: scenario.name,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze backpressure handling
|
||||
const validScenarios = results.scenarios.filter(s => !s.error);
|
||||
if (validScenarios.length > 0) {
|
||||
results.backpressureStats = {
|
||||
avgBackpressureEvents: (validScenarios.reduce((sum, s) => sum + s.backpressureEvents, 0) / validScenarios.length).toFixed(2),
|
||||
maxBufferUtilization: Math.max(...validScenarios.map(s => parseFloat(s.avgBufferUtilization))).toFixed(2),
|
||||
recommendation: validScenarios.some(s => s.backpressureEvents > 10) ?
|
||||
'Consider increasing buffer sizes or optimizing processing speed' :
|
||||
'Backpressure handling is adequate'
|
||||
};
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 4: Corpus streaming analysis
|
||||
const corpusStreaming = await performanceTracker.measureAsync(
|
||||
'corpus-streaming-analysis',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
streamableFiles: 0,
|
||||
nonStreamableFiles: 0,
|
||||
processingStats: {
|
||||
streamed: [],
|
||||
traditional: []
|
||||
},
|
||||
comparison: null
|
||||
};
|
||||
|
||||
// Process sample files both ways
|
||||
const sampleFiles = files.slice(0, 20);
|
||||
|
||||
for (const file of sampleFiles) {
|
||||
try {
|
||||
const stats = await plugins.fs.stat(file);
|
||||
const fileSize = stats.size;
|
||||
|
||||
// Traditional processing
|
||||
const traditionalStart = Date.now();
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
const format = await einvoice.detectFormat(content);
|
||||
if (format && format !== 'unknown') {
|
||||
const invoice = await einvoice.parseInvoice(content, format);
|
||||
await einvoice.validateInvoice(invoice);
|
||||
}
|
||||
const traditionalEnd = Date.now();
|
||||
|
||||
results.processingStats.traditional.push({
|
||||
size: fileSize,
|
||||
time: traditionalEnd - traditionalStart
|
||||
});
|
||||
|
||||
// Simulated streaming (chunked reading)
|
||||
const streamingStart = Date.now();
|
||||
const chunkSize = 64 * 1024; // 64KB chunks
|
||||
const chunks = [];
|
||||
|
||||
// Read in chunks
|
||||
const fd = await plugins.fs.open(file, 'r');
|
||||
const buffer = Buffer.alloc(chunkSize);
|
||||
let position = 0;
|
||||
|
||||
while (true) {
|
||||
const { bytesRead } = await fd.read(buffer, 0, chunkSize, position);
|
||||
if (bytesRead === 0) break;
|
||||
|
||||
chunks.push(buffer.slice(0, bytesRead).toString('utf-8'));
|
||||
position += bytesRead;
|
||||
}
|
||||
|
||||
await fd.close();
|
||||
|
||||
// Process accumulated content
|
||||
const streamedContent = chunks.join('');
|
||||
const streamedFormat = await einvoice.detectFormat(streamedContent);
|
||||
if (streamedFormat && streamedFormat !== 'unknown') {
|
||||
const invoice = await einvoice.parseInvoice(streamedContent, streamedFormat);
|
||||
await einvoice.validateInvoice(invoice);
|
||||
}
|
||||
const streamingEnd = Date.now();
|
||||
|
||||
results.processingStats.streamed.push({
|
||||
size: fileSize,
|
||||
time: streamingEnd - streamingStart,
|
||||
chunks: chunks.length
|
||||
});
|
||||
|
||||
// Determine if file benefits from streaming
|
||||
if (fileSize > 100 * 1024) { // Files > 100KB
|
||||
results.streamableFiles++;
|
||||
} else {
|
||||
results.nonStreamableFiles++;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Skip files that can't be processed
|
||||
}
|
||||
}
|
||||
|
||||
// Compare approaches
|
||||
if (results.processingStats.traditional.length > 0 && results.processingStats.streamed.length > 0) {
|
||||
const avgTraditional = results.processingStats.traditional.reduce((sum, s) => sum + s.time, 0) /
|
||||
results.processingStats.traditional.length;
|
||||
const avgStreamed = results.processingStats.streamed.reduce((sum, s) => sum + s.time, 0) /
|
||||
results.processingStats.streamed.length;
|
||||
|
||||
const largeFiles = results.processingStats.traditional.filter(s => s.size > 100 * 1024);
|
||||
const avgTraditionalLarge = largeFiles.length > 0 ?
|
||||
largeFiles.reduce((sum, s) => sum + s.time, 0) / largeFiles.length : 0;
|
||||
|
||||
const largeStreamed = results.processingStats.streamed.filter(s => s.size > 100 * 1024);
|
||||
const avgStreamedLarge = largeStreamed.length > 0 ?
|
||||
largeStreamed.reduce((sum, s) => sum + s.time, 0) / largeStreamed.length : 0;
|
||||
|
||||
results.comparison = {
|
||||
avgTraditionalTime: avgTraditional.toFixed(2),
|
||||
avgStreamedTime: avgStreamed.toFixed(2),
|
||||
overheadPercent: ((avgStreamed - avgTraditional) / avgTraditional * 100).toFixed(2),
|
||||
largeFileImprovement: avgTraditionalLarge > 0 && avgStreamedLarge > 0 ?
|
||||
((avgTraditionalLarge - avgStreamedLarge) / avgTraditionalLarge * 100).toFixed(2) : 'N/A',
|
||||
recommendation: avgStreamed < avgTraditional * 1.1 ?
|
||||
'Streaming provides benefits for this workload' :
|
||||
'Traditional processing is more efficient for this workload'
|
||||
};
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 5: Real-time streaming performance
|
||||
const realtimeStreaming = await performanceTracker.measureAsync(
|
||||
'realtime-streaming',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
latencyTests: [],
|
||||
jitterAnalysis: null
|
||||
};
|
||||
|
||||
// Test real-time processing with different arrival rates
|
||||
const arrivalRates = [
|
||||
{ name: 'Low rate', invoicesPerSecond: 10 },
|
||||
{ name: 'Medium rate', invoicesPerSecond: 50 },
|
||||
{ name: 'High rate', invoicesPerSecond: 100 },
|
||||
{ name: 'Burst rate', invoicesPerSecond: 200 }
|
||||
];
|
||||
|
||||
for (const rate of arrivalRates) {
|
||||
const testDuration = 5000; // 5 seconds
|
||||
const interval = 1000 / rate.invoicesPerSecond;
|
||||
const latencies = [];
|
||||
let processed = 0;
|
||||
let dropped = 0;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Create processing queue
|
||||
const queue = [];
|
||||
let processing = false;
|
||||
|
||||
const processNext = async () => {
|
||||
if (processing || queue.length === 0) return;
|
||||
|
||||
processing = true;
|
||||
const item = queue.shift();
|
||||
|
||||
try {
|
||||
const processStart = Date.now();
|
||||
const format = await einvoice.detectFormat(item.content);
|
||||
const invoice = await einvoice.parseInvoice(item.content, format || 'ubl');
|
||||
await einvoice.validateInvoice(invoice);
|
||||
|
||||
const latency = Date.now() - item.arrivalTime;
|
||||
latencies.push(latency);
|
||||
processed++;
|
||||
} catch (error) {
|
||||
dropped++;
|
||||
}
|
||||
|
||||
processing = false;
|
||||
if (queue.length > 0) {
|
||||
setImmediate(processNext);
|
||||
}
|
||||
};
|
||||
|
||||
// Generate invoices at specified rate
|
||||
const generator = setInterval(() => {
|
||||
const invoice = {
|
||||
arrivalTime: Date.now(),
|
||||
content: `<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ID>RT-${Date.now()}</ID><IssueDate>2024-03-01</IssueDate></Invoice>`
|
||||
};
|
||||
|
||||
// Apply backpressure - drop if queue is too large
|
||||
if (queue.length < 100) {
|
||||
queue.push(invoice);
|
||||
processNext();
|
||||
} else {
|
||||
dropped++;
|
||||
}
|
||||
}, interval);
|
||||
|
||||
// Run test
|
||||
await new Promise(resolve => setTimeout(resolve, testDuration));
|
||||
clearInterval(generator);
|
||||
|
||||
// Process remaining items
|
||||
while (queue.length > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
if (latencies.length > 0) {
|
||||
latencies.sort((a, b) => a - b);
|
||||
const avgLatency = latencies.reduce((a, b) => a + b, 0) / latencies.length;
|
||||
const p50 = latencies[Math.floor(latencies.length * 0.5)];
|
||||
const p95 = latencies[Math.floor(latencies.length * 0.95)];
|
||||
const p99 = latencies[Math.floor(latencies.length * 0.99)];
|
||||
|
||||
// Calculate jitter
|
||||
const jitters = [];
|
||||
for (let i = 1; i < latencies.length; i++) {
|
||||
jitters.push(Math.abs(latencies[i] - latencies[i - 1]));
|
||||
}
|
||||
const avgJitter = jitters.length > 0 ?
|
||||
jitters.reduce((a, b) => a + b, 0) / jitters.length : 0;
|
||||
|
||||
results.latencyTests.push({
|
||||
rate: rate.name,
|
||||
targetRate: rate.invoicesPerSecond,
|
||||
processed,
|
||||
dropped,
|
||||
actualRate: (processed / (testDuration / 1000)).toFixed(2),
|
||||
avgLatency: avgLatency.toFixed(2),
|
||||
p50Latency: p50,
|
||||
p95Latency: p95,
|
||||
p99Latency: p99,
|
||||
avgJitter: avgJitter.toFixed(2),
|
||||
dropRate: ((dropped / (processed + dropped)) * 100).toFixed(2)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze jitter and stability
|
||||
if (results.latencyTests.length > 0) {
|
||||
const avgJitters = results.latencyTests.map(t => parseFloat(t.avgJitter));
|
||||
const avgDropRates = results.latencyTests.map(t => parseFloat(t.dropRate));
|
||||
|
||||
results.jitterAnalysis = {
|
||||
avgJitter: (avgJitters.reduce((a, b) => a + b, 0) / avgJitters.length).toFixed(2),
|
||||
maxJitter: Math.max(...avgJitters).toFixed(2),
|
||||
avgDropRate: (avgDropRates.reduce((a, b) => a + b, 0) / avgDropRates.length).toFixed(2),
|
||||
stable: Math.max(...avgJitters) < 50 && Math.max(...avgDropRates) < 5,
|
||||
recommendation: Math.max(...avgDropRates) > 10 ?
|
||||
'System cannot handle high arrival rates - consider scaling or optimization' :
|
||||
'System handles real-time streaming adequately'
|
||||
};
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Summary
|
||||
t.comment('\n=== PERF-09: Streaming Performance Test Summary ===');
|
||||
|
||||
t.comment('\nStreaming XML Parsing:');
|
||||
t.comment(' Stream Size | Items | Data | Duration | Memory | Peak | Throughput');
|
||||
t.comment(' ------------|-------|---------|----------|--------|--------|----------');
|
||||
streamingXMLParsing.result.tests.forEach(test => {
|
||||
if (!test.error) {
|
||||
t.comment(` ${test.size.padEnd(11)} | ${String(test.items).padEnd(5)} | ${test.totalBytes.padEnd(7)}KB | ${String(test.duration + 'ms').padEnd(8)} | ${test.memoryUsed.padEnd(6)}MB | ${test.peakMemory.padEnd(6)}MB | ${test.throughput}KB/s`);
|
||||
}
|
||||
});
|
||||
if (streamingXMLParsing.result.memoryEfficiency) {
|
||||
t.comment(` Memory efficiency: ${streamingXMLParsing.result.memoryEfficiency.efficient ? 'GOOD ✅' : 'POOR ⚠️'}`);
|
||||
t.comment(` Scaling: ${streamingXMLParsing.result.memoryEfficiency.memoryScaling}x memory for ${streamingXMLParsing.result.memoryEfficiency.itemScaling}x items`);
|
||||
}
|
||||
|
||||
t.comment('\nStream Transformation Pipeline:');
|
||||
streamTransformation.result.pipelines.forEach(pipeline => {
|
||||
if (!pipeline.error) {
|
||||
t.comment(` ${pipeline.name}:`);
|
||||
t.comment(` - Stages: ${pipeline.stages}, Items: ${pipeline.itemsProcessed}`);
|
||||
t.comment(` - Duration: ${pipeline.duration}ms, Throughput: ${pipeline.throughput}/s`);
|
||||
t.comment(` - Valid: ${pipeline.validItems}, Errors: ${pipeline.errorItems}`);
|
||||
}
|
||||
});
|
||||
if (streamTransformation.result.transformationStats) {
|
||||
t.comment(` Best pipeline: ${streamTransformation.result.transformationStats.bestPipeline} (${streamTransformation.result.transformationStats.bestThroughput}/s)`);
|
||||
}
|
||||
|
||||
t.comment('\nBackpressure Handling:');
|
||||
t.comment(' Scenario | Duration | Produced | Consumed | Max Buffer | BP Events | Efficiency');
|
||||
t.comment(' ----------------------------|----------|----------|----------|------------|-----------|----------');
|
||||
backpressureHandling.result.scenarios.forEach(scenario => {
|
||||
if (!scenario.error) {
|
||||
t.comment(` ${scenario.name.padEnd(27)} | ${String(scenario.duration + 'ms').padEnd(8)} | ${String(scenario.produced).padEnd(8)} | ${String(scenario.consumed).padEnd(8)} | ${String(scenario.maxBuffered).padEnd(10)} | ${String(scenario.backpressureEvents).padEnd(9)} | ${scenario.efficiency}%`);
|
||||
}
|
||||
});
|
||||
if (backpressureHandling.result.backpressureStats) {
|
||||
t.comment(` ${backpressureHandling.result.backpressureStats.recommendation}`);
|
||||
}
|
||||
|
||||
t.comment('\nCorpus Streaming Analysis:');
|
||||
t.comment(` Streamable files: ${corpusStreaming.result.streamableFiles}`);
|
||||
t.comment(` Non-streamable files: ${corpusStreaming.result.nonStreamableFiles}`);
|
||||
if (corpusStreaming.result.comparison) {
|
||||
t.comment(` Traditional avg: ${corpusStreaming.result.comparison.avgTraditionalTime}ms`);
|
||||
t.comment(` Streamed avg: ${corpusStreaming.result.comparison.avgStreamedTime}ms`);
|
||||
t.comment(` Overhead: ${corpusStreaming.result.comparison.overheadPercent}%`);
|
||||
t.comment(` Large file improvement: ${corpusStreaming.result.comparison.largeFileImprovement}%`);
|
||||
t.comment(` ${corpusStreaming.result.comparison.recommendation}`);
|
||||
}
|
||||
|
||||
t.comment('\nReal-time Streaming:');
|
||||
t.comment(' Rate | Target | Actual | Processed | Dropped | Avg Latency | P95 | Jitter');
|
||||
t.comment(' ------------|--------|--------|-----------|---------|-------------|--------|-------');
|
||||
realtimeStreaming.result.latencyTests.forEach(test => {
|
||||
t.comment(` ${test.rate.padEnd(11)} | ${String(test.targetRate).padEnd(6)} | ${test.actualRate.padEnd(6)} | ${String(test.processed).padEnd(9)} | ${test.dropRate.padEnd(7)}% | ${test.avgLatency.padEnd(11)}ms | ${String(test.p95Latency).padEnd(6)}ms | ${test.avgJitter}ms`);
|
||||
});
|
||||
if (realtimeStreaming.result.jitterAnalysis) {
|
||||
t.comment(` System stability: ${realtimeStreaming.result.jitterAnalysis.stable ? 'STABLE ✅' : 'UNSTABLE ⚠️'}`);
|
||||
t.comment(` ${realtimeStreaming.result.jitterAnalysis.recommendation}`);
|
||||
}
|
||||
|
||||
// Performance targets check
|
||||
t.comment('\n=== Performance Targets Check ===');
|
||||
const streamingEfficient = streamingXMLParsing.result.memoryEfficiency?.efficient || false;
|
||||
const realtimeStable = realtimeStreaming.result.jitterAnalysis?.stable || false;
|
||||
|
||||
t.comment(`Streaming memory efficiency: ${streamingEfficient ? 'EFFICIENT ✅' : 'INEFFICIENT ⚠️'}`);
|
||||
t.comment(`Real-time stability: ${realtimeStable ? 'STABLE ✅' : 'UNSTABLE ⚠️'}`);
|
||||
|
||||
// Overall performance summary
|
||||
t.comment('\n=== Overall Performance Summary ===');
|
||||
performanceTracker.logSummary();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.start();
|
719
test/suite/einvoice_performance/test.perf-10.cache-efficiency.ts
Normal file
719
test/suite/einvoice_performance/test.perf-10.cache-efficiency.ts
Normal file
@ -0,0 +1,719 @@
|
||||
/**
|
||||
* @file test.perf-10.cache-efficiency.ts
|
||||
* @description Performance tests for cache efficiency and optimization
|
||||
*/
|
||||
|
||||
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-10: Cache Efficiency');
|
||||
|
||||
tap.test('PERF-10: Cache Efficiency - should demonstrate effective caching strategies', async (t) => {
|
||||
// Test 1: Format detection cache
|
||||
const formatDetectionCache = await performanceTracker.measureAsync(
|
||||
'format-detection-cache',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
withoutCache: {
|
||||
iterations: 0,
|
||||
totalTime: 0,
|
||||
avgTime: 0
|
||||
},
|
||||
withCache: {
|
||||
iterations: 0,
|
||||
totalTime: 0,
|
||||
avgTime: 0,
|
||||
cacheHits: 0,
|
||||
cacheMisses: 0
|
||||
},
|
||||
improvement: null
|
||||
};
|
||||
|
||||
// Test data
|
||||
const testDocuments = [
|
||||
{
|
||||
id: 'ubl-1',
|
||||
content: '<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ID>UBL-001</ID></Invoice>'
|
||||
},
|
||||
{
|
||||
id: 'cii-1',
|
||||
content: '<?xml version="1.0"?><rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"><ID>CII-001</ID></rsm:CrossIndustryInvoice>'
|
||||
},
|
||||
{
|
||||
id: 'unknown-1',
|
||||
content: '<?xml version="1.0"?><UnknownFormat><ID>UNKNOWN-001</ID></UnknownFormat>'
|
||||
}
|
||||
];
|
||||
|
||||
// Test without cache (baseline)
|
||||
const iterations = 100;
|
||||
const startWithoutCache = Date.now();
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
for (const doc of testDocuments) {
|
||||
await einvoice.detectFormat(doc.content);
|
||||
results.withoutCache.iterations++;
|
||||
}
|
||||
}
|
||||
|
||||
results.withoutCache.totalTime = Date.now() - startWithoutCache;
|
||||
results.withoutCache.avgTime = results.withoutCache.totalTime / results.withoutCache.iterations;
|
||||
|
||||
// Implement simple cache
|
||||
const formatCache = new Map<string, { format: string; timestamp: number }>();
|
||||
const cacheMaxAge = 60000; // 1 minute
|
||||
|
||||
const detectFormatWithCache = async (content: string) => {
|
||||
// Create cache key from content hash
|
||||
const hash = Buffer.from(content).toString('base64').slice(0, 20);
|
||||
|
||||
// Check cache
|
||||
const cached = formatCache.get(hash);
|
||||
if (cached && Date.now() - cached.timestamp < cacheMaxAge) {
|
||||
results.withCache.cacheHits++;
|
||||
return cached.format;
|
||||
}
|
||||
|
||||
// Cache miss
|
||||
results.withCache.cacheMisses++;
|
||||
const format = await einvoice.detectFormat(content);
|
||||
|
||||
// Store in cache
|
||||
formatCache.set(hash, { format: format || 'unknown', timestamp: Date.now() });
|
||||
|
||||
return format;
|
||||
};
|
||||
|
||||
// Test with cache
|
||||
const startWithCache = Date.now();
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
for (const doc of testDocuments) {
|
||||
await detectFormatWithCache(doc.content);
|
||||
results.withCache.iterations++;
|
||||
}
|
||||
}
|
||||
|
||||
results.withCache.totalTime = Date.now() - startWithCache;
|
||||
results.withCache.avgTime = results.withCache.totalTime / results.withCache.iterations;
|
||||
|
||||
// Calculate improvement
|
||||
results.improvement = {
|
||||
speedup: (results.withoutCache.avgTime / results.withCache.avgTime).toFixed(2),
|
||||
timeReduction: ((results.withoutCache.totalTime - results.withCache.totalTime) / results.withoutCache.totalTime * 100).toFixed(2),
|
||||
hitRate: ((results.withCache.cacheHits / results.withCache.iterations) * 100).toFixed(2),
|
||||
efficiency: results.withCache.cacheHits > 0 ?
|
||||
((results.withCache.cacheHits / (results.withCache.cacheHits + results.withCache.cacheMisses)) * 100).toFixed(2) : '0'
|
||||
};
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 2: Validation cache
|
||||
const validationCache = await performanceTracker.measureAsync(
|
||||
'validation-cache',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
cacheStrategies: [],
|
||||
optimalStrategy: null
|
||||
};
|
||||
|
||||
// Test invoice
|
||||
const testInvoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: 'CACHE-VAL-001',
|
||||
issueDate: '2024-03-05',
|
||||
seller: { name: 'Cache Test Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: 'Cache Test Buyer', address: 'Address', country: 'US', taxId: 'US456' },
|
||||
items: Array.from({ length: 20 }, (_, i) => ({
|
||||
description: `Item ${i + 1}`,
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 10,
|
||||
lineTotal: 100
|
||||
})),
|
||||
totals: { netAmount: 2000, vatAmount: 200, grossAmount: 2200 }
|
||||
}
|
||||
};
|
||||
|
||||
// Cache strategies to test
|
||||
const strategies = [
|
||||
{
|
||||
name: 'No cache',
|
||||
cacheSize: 0,
|
||||
ttl: 0
|
||||
},
|
||||
{
|
||||
name: 'Small cache',
|
||||
cacheSize: 10,
|
||||
ttl: 30000
|
||||
},
|
||||
{
|
||||
name: 'Medium cache',
|
||||
cacheSize: 100,
|
||||
ttl: 60000
|
||||
},
|
||||
{
|
||||
name: 'Large cache',
|
||||
cacheSize: 1000,
|
||||
ttl: 300000
|
||||
},
|
||||
{
|
||||
name: 'LRU cache',
|
||||
cacheSize: 50,
|
||||
ttl: 120000,
|
||||
lru: true
|
||||
}
|
||||
];
|
||||
|
||||
for (const strategy of strategies) {
|
||||
const cache = new Map<string, { result: any; timestamp: number; accessCount: number }>();
|
||||
let cacheHits = 0;
|
||||
let cacheMisses = 0;
|
||||
|
||||
const validateWithCache = async (invoice: any) => {
|
||||
const key = JSON.stringify(invoice).slice(0, 50); // Simple key generation
|
||||
|
||||
// Check cache
|
||||
const cached = cache.get(key);
|
||||
if (cached && Date.now() - cached.timestamp < strategy.ttl) {
|
||||
cacheHits++;
|
||||
cached.accessCount++;
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
// Cache miss
|
||||
cacheMisses++;
|
||||
const result = await einvoice.validateInvoice(invoice);
|
||||
|
||||
// Cache management
|
||||
if (strategy.cacheSize > 0) {
|
||||
if (cache.size >= strategy.cacheSize) {
|
||||
if (strategy.lru) {
|
||||
// Remove least recently used
|
||||
let lruKey = '';
|
||||
let minAccess = Infinity;
|
||||
for (const [k, v] of cache.entries()) {
|
||||
if (v.accessCount < minAccess) {
|
||||
minAccess = v.accessCount;
|
||||
lruKey = k;
|
||||
}
|
||||
}
|
||||
cache.delete(lruKey);
|
||||
} else {
|
||||
// Remove oldest
|
||||
const oldestKey = cache.keys().next().value;
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
cache.set(key, { result, timestamp: Date.now(), accessCount: 1 });
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Test with mixed workload
|
||||
const workload = [];
|
||||
|
||||
// Repeated validations of same invoice
|
||||
for (let i = 0; i < 50; i++) {
|
||||
workload.push(testInvoice);
|
||||
}
|
||||
|
||||
// Variations of the invoice
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const variation = JSON.parse(JSON.stringify(testInvoice));
|
||||
variation.data.invoiceNumber = `CACHE-VAL-${i + 2}`;
|
||||
workload.push(variation);
|
||||
}
|
||||
|
||||
// Repeat some variations
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const variation = JSON.parse(JSON.stringify(testInvoice));
|
||||
variation.data.invoiceNumber = `CACHE-VAL-${(i % 10) + 2}`;
|
||||
workload.push(variation);
|
||||
}
|
||||
|
||||
// Process workload
|
||||
const startTime = Date.now();
|
||||
|
||||
for (const invoice of workload) {
|
||||
await validateWithCache(invoice);
|
||||
}
|
||||
|
||||
const totalTime = Date.now() - startTime;
|
||||
|
||||
results.cacheStrategies.push({
|
||||
name: strategy.name,
|
||||
cacheSize: strategy.cacheSize,
|
||||
ttl: strategy.ttl,
|
||||
lru: strategy.lru || false,
|
||||
totalRequests: workload.length,
|
||||
cacheHits,
|
||||
cacheMisses,
|
||||
hitRate: ((cacheHits / workload.length) * 100).toFixed(2),
|
||||
totalTime,
|
||||
avgTime: (totalTime / workload.length).toFixed(2),
|
||||
finalCacheSize: cache.size,
|
||||
memoryUsage: (cache.size * 1024).toFixed(0) // Rough estimate in bytes
|
||||
});
|
||||
}
|
||||
|
||||
// Find optimal strategy
|
||||
const validStrategies = results.cacheStrategies.filter(s => s.cacheSize > 0);
|
||||
if (validStrategies.length > 0) {
|
||||
results.optimalStrategy = validStrategies.reduce((best, current) => {
|
||||
const bestScore = parseFloat(best.hitRate) / (parseFloat(best.avgTime) + 1);
|
||||
const currentScore = parseFloat(current.hitRate) / (parseFloat(current.avgTime) + 1);
|
||||
return currentScore > bestScore ? current : best;
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 3: Schema cache efficiency
|
||||
const schemaCache = await performanceTracker.measureAsync(
|
||||
'schema-cache-efficiency',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
schemaCaching: {
|
||||
enabled: false,
|
||||
tests: []
|
||||
},
|
||||
improvement: null
|
||||
};
|
||||
|
||||
// Simulate schema validation with and without caching
|
||||
const schemas = {
|
||||
ubl: { size: 1024 * 50, parseTime: 50 }, // 50KB, 50ms parse time
|
||||
cii: { size: 1024 * 60, parseTime: 60 }, // 60KB, 60ms parse time
|
||||
zugferd: { size: 1024 * 80, parseTime: 80 }, // 80KB, 80ms parse time
|
||||
xrechnung: { size: 1024 * 70, parseTime: 70 } // 70KB, 70ms parse time
|
||||
};
|
||||
|
||||
const schemaCache = new Map<string, { schema: any; loadTime: number }>();
|
||||
|
||||
const loadSchemaWithoutCache = async (format: string) => {
|
||||
const schema = schemas[format];
|
||||
if (schema) {
|
||||
await new Promise(resolve => setTimeout(resolve, schema.parseTime));
|
||||
return { format, size: schema.size };
|
||||
}
|
||||
throw new Error(`Unknown schema format: ${format}`);
|
||||
};
|
||||
|
||||
const loadSchemaWithCache = async (format: string) => {
|
||||
const cached = schemaCache.get(format);
|
||||
if (cached) {
|
||||
results.schemaCaching.enabled = true;
|
||||
return cached.schema;
|
||||
}
|
||||
|
||||
const schema = await loadSchemaWithoutCache(format);
|
||||
schemaCache.set(format, { schema, loadTime: Date.now() });
|
||||
return schema;
|
||||
};
|
||||
|
||||
// Test workload
|
||||
const workload = [];
|
||||
const formats = Object.keys(schemas);
|
||||
|
||||
// Initial load of each schema
|
||||
for (const format of formats) {
|
||||
workload.push(format);
|
||||
}
|
||||
|
||||
// Repeated use of schemas
|
||||
for (let i = 0; i < 100; i++) {
|
||||
workload.push(formats[i % formats.length]);
|
||||
}
|
||||
|
||||
// Test without cache
|
||||
const startWithoutCache = Date.now();
|
||||
for (const format of workload) {
|
||||
await loadSchemaWithoutCache(format);
|
||||
}
|
||||
const timeWithoutCache = Date.now() - startWithoutCache;
|
||||
|
||||
// Test with cache
|
||||
const startWithCache = Date.now();
|
||||
for (const format of workload) {
|
||||
await loadSchemaWithCache(format);
|
||||
}
|
||||
const timeWithCache = Date.now() - startWithCache;
|
||||
|
||||
// Calculate memory usage
|
||||
let totalCachedSize = 0;
|
||||
for (const format of schemaCache.keys()) {
|
||||
totalCachedSize += schemas[format].size;
|
||||
}
|
||||
|
||||
results.improvement = {
|
||||
timeWithoutCache,
|
||||
timeWithCache,
|
||||
speedup: (timeWithoutCache / timeWithCache).toFixed(2),
|
||||
timeReduction: ((timeWithoutCache - timeWithCache) / timeWithoutCache * 100).toFixed(2),
|
||||
memoryCost: (totalCachedSize / 1024).toFixed(2), // KB
|
||||
schemasLoaded: workload.length,
|
||||
uniqueSchemas: schemaCache.size
|
||||
};
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 4: Corpus cache analysis
|
||||
const corpusCacheAnalysis = await performanceTracker.measureAsync(
|
||||
'corpus-cache-analysis',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
cacheableOperations: {
|
||||
formatDetection: { count: 0, duplicates: 0 },
|
||||
parsing: { count: 0, duplicates: 0 },
|
||||
validation: { count: 0, duplicates: 0 }
|
||||
},
|
||||
potentialSavings: null
|
||||
};
|
||||
|
||||
// Track unique content hashes
|
||||
const contentHashes = new Map<string, number>();
|
||||
const formatResults = new Map<string, string>();
|
||||
|
||||
// Sample corpus files
|
||||
const sampleFiles = files.slice(0, 100);
|
||||
|
||||
for (const file of sampleFiles) {
|
||||
try {
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
const hash = Buffer.from(content).toString('base64').slice(0, 32);
|
||||
|
||||
// Track content duplicates
|
||||
const count = contentHashes.get(hash) || 0;
|
||||
contentHashes.set(hash, count + 1);
|
||||
|
||||
if (count > 0) {
|
||||
results.cacheableOperations.formatDetection.duplicates++;
|
||||
results.cacheableOperations.parsing.duplicates++;
|
||||
results.cacheableOperations.validation.duplicates++;
|
||||
}
|
||||
|
||||
// Perform operations
|
||||
const format = await einvoice.detectFormat(content);
|
||||
results.cacheableOperations.formatDetection.count++;
|
||||
|
||||
if (format && format !== 'unknown') {
|
||||
formatResults.set(hash, format);
|
||||
|
||||
const invoice = await einvoice.parseInvoice(content, format);
|
||||
results.cacheableOperations.parsing.count++;
|
||||
|
||||
await einvoice.validateInvoice(invoice);
|
||||
results.cacheableOperations.validation.count++;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Skip failed files
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate potential savings
|
||||
const avgFormatDetectionTime = 5; // ms
|
||||
const avgParsingTime = 20; // ms
|
||||
const avgValidationTime = 50; // ms
|
||||
|
||||
results.potentialSavings = {
|
||||
formatDetection: {
|
||||
duplicateRatio: (results.cacheableOperations.formatDetection.duplicates /
|
||||
results.cacheableOperations.formatDetection.count * 100).toFixed(2),
|
||||
timeSavings: results.cacheableOperations.formatDetection.duplicates * avgFormatDetectionTime
|
||||
},
|
||||
parsing: {
|
||||
duplicateRatio: (results.cacheableOperations.parsing.duplicates /
|
||||
results.cacheableOperations.parsing.count * 100).toFixed(2),
|
||||
timeSavings: results.cacheableOperations.parsing.duplicates * avgParsingTime
|
||||
},
|
||||
validation: {
|
||||
duplicateRatio: (results.cacheableOperations.validation.duplicates /
|
||||
results.cacheableOperations.validation.count * 100).toFixed(2),
|
||||
timeSavings: results.cacheableOperations.validation.duplicates * avgValidationTime
|
||||
},
|
||||
totalTimeSavings: results.cacheableOperations.formatDetection.duplicates * avgFormatDetectionTime +
|
||||
results.cacheableOperations.parsing.duplicates * avgParsingTime +
|
||||
results.cacheableOperations.validation.duplicates * avgValidationTime,
|
||||
memoryCost: contentHashes.size * 100 // Rough estimate: 100 bytes per cached item
|
||||
};
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 5: Cache invalidation strategies
|
||||
const cacheInvalidation = await performanceTracker.measureAsync(
|
||||
'cache-invalidation-strategies',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
strategies: [],
|
||||
bestStrategy: null
|
||||
};
|
||||
|
||||
// Test different invalidation strategies
|
||||
const strategies = [
|
||||
{
|
||||
name: 'TTL only',
|
||||
ttl: 60000,
|
||||
maxSize: Infinity,
|
||||
policy: 'ttl'
|
||||
},
|
||||
{
|
||||
name: 'Size limited',
|
||||
ttl: Infinity,
|
||||
maxSize: 50,
|
||||
policy: 'fifo'
|
||||
},
|
||||
{
|
||||
name: 'LRU with TTL',
|
||||
ttl: 120000,
|
||||
maxSize: 100,
|
||||
policy: 'lru'
|
||||
},
|
||||
{
|
||||
name: 'Adaptive',
|
||||
ttl: 60000,
|
||||
maxSize: 100,
|
||||
policy: 'adaptive'
|
||||
}
|
||||
];
|
||||
|
||||
for (const strategy of strategies) {
|
||||
const cache = new Map<string, {
|
||||
data: any;
|
||||
timestamp: number;
|
||||
accessCount: number;
|
||||
lastAccess: number;
|
||||
size: number;
|
||||
}>();
|
||||
|
||||
let hits = 0;
|
||||
let misses = 0;
|
||||
let evictions = 0;
|
||||
|
||||
const cacheGet = (key: string) => {
|
||||
const entry = cache.get(key);
|
||||
|
||||
if (!entry) {
|
||||
misses++;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check TTL
|
||||
if (strategy.ttl !== Infinity && Date.now() - entry.timestamp > strategy.ttl) {
|
||||
cache.delete(key);
|
||||
evictions++;
|
||||
misses++;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update access info
|
||||
entry.accessCount++;
|
||||
entry.lastAccess = Date.now();
|
||||
hits++;
|
||||
|
||||
return entry.data;
|
||||
};
|
||||
|
||||
const cacheSet = (key: string, data: any, size: number = 1) => {
|
||||
// Check size limit
|
||||
if (cache.size >= strategy.maxSize) {
|
||||
let keyToEvict = '';
|
||||
|
||||
switch (strategy.policy) {
|
||||
case 'fifo':
|
||||
keyToEvict = cache.keys().next().value;
|
||||
break;
|
||||
|
||||
case 'lru':
|
||||
let oldestAccess = Infinity;
|
||||
for (const [k, v] of cache.entries()) {
|
||||
if (v.lastAccess < oldestAccess) {
|
||||
oldestAccess = v.lastAccess;
|
||||
keyToEvict = k;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'adaptive':
|
||||
// Evict based on access frequency and age
|
||||
let lowestScore = Infinity;
|
||||
for (const [k, v] of cache.entries()) {
|
||||
const age = Date.now() - v.timestamp;
|
||||
const score = v.accessCount / (age / 1000);
|
||||
if (score < lowestScore) {
|
||||
lowestScore = score;
|
||||
keyToEvict = k;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (keyToEvict) {
|
||||
cache.delete(keyToEvict);
|
||||
evictions++;
|
||||
}
|
||||
}
|
||||
|
||||
cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
accessCount: 0,
|
||||
lastAccess: Date.now(),
|
||||
size
|
||||
});
|
||||
};
|
||||
|
||||
// Simulate workload with temporal locality
|
||||
const workloadSize = 500;
|
||||
const uniqueItems = 200;
|
||||
const workload = [];
|
||||
|
||||
// Generate workload with patterns
|
||||
for (let i = 0; i < workloadSize; i++) {
|
||||
if (i < 100) {
|
||||
// Initial unique accesses
|
||||
workload.push(`item-${i % uniqueItems}`);
|
||||
} else if (i < 300) {
|
||||
// Repeated access to popular items
|
||||
workload.push(`item-${Math.floor(Math.random() * 20)}`);
|
||||
} else {
|
||||
// Mixed access pattern
|
||||
if (Math.random() < 0.3) {
|
||||
// Access recent item
|
||||
workload.push(`item-${Math.floor(Math.random() * 50)}`);
|
||||
} else {
|
||||
// Access any item
|
||||
workload.push(`item-${Math.floor(Math.random() * uniqueItems)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process workload
|
||||
const startTime = Date.now();
|
||||
|
||||
for (const key of workload) {
|
||||
const cached = cacheGet(key);
|
||||
if (!cached) {
|
||||
// Simulate data generation
|
||||
const data = { key, value: Math.random() };
|
||||
cacheSet(key, data);
|
||||
}
|
||||
}
|
||||
|
||||
const totalTime = Date.now() - startTime;
|
||||
|
||||
results.strategies.push({
|
||||
name: strategy.name,
|
||||
policy: strategy.policy,
|
||||
ttl: strategy.ttl,
|
||||
maxSize: strategy.maxSize,
|
||||
hits,
|
||||
misses,
|
||||
hitRate: ((hits / (hits + misses)) * 100).toFixed(2),
|
||||
evictions,
|
||||
evictionRate: ((evictions / workloadSize) * 100).toFixed(2),
|
||||
finalCacheSize: cache.size,
|
||||
totalTime,
|
||||
avgAccessTime: (totalTime / workloadSize).toFixed(2)
|
||||
});
|
||||
}
|
||||
|
||||
// Find best strategy
|
||||
results.bestStrategy = results.strategies.reduce((best, current) => {
|
||||
const bestScore = parseFloat(best.hitRate) - parseFloat(best.evictionRate);
|
||||
const currentScore = parseFloat(current.hitRate) - parseFloat(current.evictionRate);
|
||||
return currentScore > bestScore ? current : best;
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Summary
|
||||
t.comment('\n=== PERF-10: Cache Efficiency Test Summary ===');
|
||||
|
||||
t.comment('\nFormat Detection Cache:');
|
||||
t.comment(` Without cache: ${formatDetectionCache.result.withoutCache.totalTime}ms for ${formatDetectionCache.result.withoutCache.iterations} ops`);
|
||||
t.comment(` With cache: ${formatDetectionCache.result.withCache.totalTime}ms for ${formatDetectionCache.result.withCache.iterations} ops`);
|
||||
t.comment(` Cache hits: ${formatDetectionCache.result.withCache.cacheHits}, misses: ${formatDetectionCache.result.withCache.cacheMisses}`);
|
||||
t.comment(` Speedup: ${formatDetectionCache.result.improvement.speedup}x`);
|
||||
t.comment(` Hit rate: ${formatDetectionCache.result.improvement.hitRate}%`);
|
||||
t.comment(` Time reduction: ${formatDetectionCache.result.improvement.timeReduction}%`);
|
||||
|
||||
t.comment('\nValidation Cache Strategies:');
|
||||
t.comment(' Strategy | Size | TTL | Requests | Hits | Hit Rate | Avg Time | Memory');
|
||||
t.comment(' -------------|------|--------|----------|------|----------|----------|--------');
|
||||
validationCache.result.cacheStrategies.forEach(strategy => {
|
||||
t.comment(` ${strategy.name.padEnd(12)} | ${String(strategy.cacheSize).padEnd(4)} | ${String(strategy.ttl).padEnd(6)} | ${String(strategy.totalRequests).padEnd(8)} | ${String(strategy.cacheHits).padEnd(4)} | ${strategy.hitRate.padEnd(8)}% | ${strategy.avgTime.padEnd(8)}ms | ${strategy.memoryUsage}B`);
|
||||
});
|
||||
if (validationCache.result.optimalStrategy) {
|
||||
t.comment(` Optimal strategy: ${validationCache.result.optimalStrategy.name}`);
|
||||
}
|
||||
|
||||
t.comment('\nSchema Cache Efficiency:');
|
||||
t.comment(` Without cache: ${schemaCache.result.improvement.timeWithoutCache}ms`);
|
||||
t.comment(` With cache: ${schemaCache.result.improvement.timeWithCache}ms`);
|
||||
t.comment(` Speedup: ${schemaCache.result.improvement.speedup}x`);
|
||||
t.comment(` Time reduction: ${schemaCache.result.improvement.timeReduction}%`);
|
||||
t.comment(` Memory cost: ${schemaCache.result.improvement.memoryCost}KB`);
|
||||
t.comment(` Schemas loaded: ${schemaCache.result.improvement.schemasLoaded}, unique: ${schemaCache.result.improvement.uniqueSchemas}`);
|
||||
|
||||
t.comment('\nCorpus Cache Analysis:');
|
||||
t.comment(' Operation | Count | Duplicates | Ratio | Time Savings');
|
||||
t.comment(' -----------------|-------|------------|--------|-------------');
|
||||
['formatDetection', 'parsing', 'validation'].forEach(op => {
|
||||
const stats = corpusCacheAnalysis.result.cacheableOperations[op];
|
||||
const savings = corpusCacheAnalysis.result.potentialSavings[op];
|
||||
t.comment(` ${op.padEnd(16)} | ${String(stats.count).padEnd(5)} | ${String(stats.duplicates).padEnd(10)} | ${savings.duplicateRatio.padEnd(6)}% | ${savings.timeSavings}ms`);
|
||||
});
|
||||
t.comment(` Total potential time savings: ${corpusCacheAnalysis.result.potentialSavings.totalTimeSavings}ms`);
|
||||
t.comment(` Estimated memory cost: ${(corpusCacheAnalysis.result.potentialSavings.memoryCost / 1024).toFixed(2)}KB`);
|
||||
|
||||
t.comment('\nCache Invalidation Strategies:');
|
||||
t.comment(' Strategy | Policy | Hits | Hit Rate | Evictions | Final Size');
|
||||
t.comment(' --------------|----------|------|----------|-----------|------------');
|
||||
cacheInvalidation.result.strategies.forEach(strategy => {
|
||||
t.comment(` ${strategy.name.padEnd(13)} | ${strategy.policy.padEnd(8)} | ${String(strategy.hits).padEnd(4)} | ${strategy.hitRate.padEnd(8)}% | ${String(strategy.evictions).padEnd(9)} | ${strategy.finalCacheSize}`);
|
||||
});
|
||||
if (cacheInvalidation.result.bestStrategy) {
|
||||
t.comment(` Best strategy: ${cacheInvalidation.result.bestStrategy.name} (${cacheInvalidation.result.bestStrategy.hitRate}% hit rate)`);
|
||||
}
|
||||
|
||||
// Performance targets check
|
||||
t.comment('\n=== Performance Targets Check ===');
|
||||
const cacheSpeedup = parseFloat(formatDetectionCache.result.improvement.speedup);
|
||||
const targetSpeedup = 2; // Target: >2x speedup with caching
|
||||
|
||||
t.comment(`Cache speedup: ${cacheSpeedup}x ${cacheSpeedup > targetSpeedup ? '✅' : '⚠️'} (target: >${targetSpeedup}x)`);
|
||||
|
||||
// Overall performance summary
|
||||
t.comment('\n=== Overall Performance Summary ===');
|
||||
performanceTracker.logSummary();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.start();
|
685
test/suite/einvoice_performance/test.perf-11.batch-processing.ts
Normal file
685
test/suite/einvoice_performance/test.perf-11.batch-processing.ts
Normal file
@ -0,0 +1,685 @@
|
||||
/**
|
||||
* @file test.perf-11.batch-processing.ts
|
||||
* @description Performance tests for batch processing operations
|
||||
*/
|
||||
|
||||
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';
|
||||
import * as os from 'os';
|
||||
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
|
||||
|
||||
const corpusLoader = new CorpusLoader();
|
||||
const performanceTracker = new PerformanceTracker('PERF-11: Batch Processing');
|
||||
|
||||
tap.test('PERF-11: Batch Processing - should handle batch operations efficiently', async (t) => {
|
||||
// Test 1: Batch size optimization
|
||||
const batchSizeOptimization = await performanceTracker.measureAsync(
|
||||
'batch-size-optimization',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
batchSizes: [],
|
||||
optimalBatchSize: 0,
|
||||
maxThroughput: 0
|
||||
};
|
||||
|
||||
// Create test invoices
|
||||
const totalInvoices = 500;
|
||||
const testInvoices = Array.from({ length: totalInvoices }, (_, i) => ({
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `BATCH-${i + 1}`,
|
||||
issueDate: '2024-03-10',
|
||||
seller: { name: `Seller ${i + 1}`, address: 'Address', country: 'US', taxId: `US${i}` },
|
||||
buyer: { name: `Buyer ${i + 1}`, address: 'Address', country: 'US', taxId: `US${i + 1000}` },
|
||||
items: Array.from({ length: 10 }, (_, j) => ({
|
||||
description: `Item ${j + 1}`,
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 10,
|
||||
lineTotal: 100
|
||||
})),
|
||||
totals: { netAmount: 1000, vatAmount: 100, grossAmount: 1100 }
|
||||
}
|
||||
}));
|
||||
|
||||
// Test different batch sizes
|
||||
const batchSizes = [1, 5, 10, 20, 50, 100, 200];
|
||||
|
||||
for (const batchSize of batchSizes) {
|
||||
const startTime = Date.now();
|
||||
let processed = 0;
|
||||
let errors = 0;
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < testInvoices.length; i += batchSize) {
|
||||
const batch = testInvoices.slice(i, Math.min(i + batchSize, testInvoices.length));
|
||||
|
||||
// Process batch
|
||||
const batchPromises = batch.map(async (invoice) => {
|
||||
try {
|
||||
await einvoice.validateInvoice(invoice);
|
||||
await einvoice.convertFormat(invoice, 'cii');
|
||||
processed++;
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors++;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(batchPromises);
|
||||
}
|
||||
|
||||
const totalTime = Date.now() - startTime;
|
||||
const throughput = (processed / (totalTime / 1000));
|
||||
|
||||
const result = {
|
||||
batchSize,
|
||||
totalTime,
|
||||
processed,
|
||||
errors,
|
||||
throughput: throughput.toFixed(2),
|
||||
avgTimePerInvoice: (totalTime / processed).toFixed(2),
|
||||
avgTimePerBatch: (totalTime / Math.ceil(totalInvoices / batchSize)).toFixed(2)
|
||||
};
|
||||
|
||||
results.batchSizes.push(result);
|
||||
|
||||
if (throughput > results.maxThroughput) {
|
||||
results.maxThroughput = throughput;
|
||||
results.optimalBatchSize = batchSize;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 2: Batch operation types
|
||||
const batchOperationTypes = await performanceTracker.measureAsync(
|
||||
'batch-operation-types',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
operations: []
|
||||
};
|
||||
|
||||
// Create test data
|
||||
const batchSize = 50;
|
||||
const testBatch = Array.from({ length: batchSize }, (_, i) => ({
|
||||
xml: `<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ID>BATCH-OP-${i}</ID><IssueDate>2024-03-10</IssueDate></Invoice>`,
|
||||
invoice: {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `BATCH-OP-${i}`,
|
||||
issueDate: '2024-03-10',
|
||||
seller: { name: 'Batch Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: 'Batch 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 }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Test different batch operations
|
||||
const operations = [
|
||||
{
|
||||
name: 'Batch format detection',
|
||||
fn: async (batch: any[]) => {
|
||||
const promises = batch.map(item => einvoice.detectFormat(item.xml));
|
||||
return await Promise.all(promises);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Batch parsing',
|
||||
fn: async (batch: any[]) => {
|
||||
const promises = batch.map(item => einvoice.parseInvoice(item.xml, 'ubl'));
|
||||
return await Promise.all(promises);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Batch validation',
|
||||
fn: async (batch: any[]) => {
|
||||
const promises = batch.map(item => einvoice.validateInvoice(item.invoice));
|
||||
return await Promise.all(promises);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Batch conversion',
|
||||
fn: async (batch: any[]) => {
|
||||
const promises = batch.map(item => einvoice.convertFormat(item.invoice, 'cii'));
|
||||
return await Promise.all(promises);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Batch pipeline',
|
||||
fn: async (batch: any[]) => {
|
||||
const promises = batch.map(async (item) => {
|
||||
const format = await einvoice.detectFormat(item.xml);
|
||||
const parsed = await einvoice.parseInvoice(item.xml, format || 'ubl');
|
||||
const validated = await einvoice.validateInvoice(parsed);
|
||||
const converted = await einvoice.convertFormat(parsed, 'cii');
|
||||
return { format, validated: validated.isValid, converted: !!converted };
|
||||
});
|
||||
return await Promise.all(promises);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
for (const operation of operations) {
|
||||
const iterations = 10;
|
||||
const times = [];
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const startTime = Date.now();
|
||||
await operation.fn(testBatch);
|
||||
const endTime = Date.now();
|
||||
times.push(endTime - startTime);
|
||||
}
|
||||
|
||||
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
const minTime = Math.min(...times);
|
||||
const maxTime = Math.max(...times);
|
||||
|
||||
results.operations.push({
|
||||
name: operation.name,
|
||||
batchSize,
|
||||
avgTime: avgTime.toFixed(2),
|
||||
minTime,
|
||||
maxTime,
|
||||
throughput: (batchSize / (avgTime / 1000)).toFixed(2),
|
||||
avgPerItem: (avgTime / batchSize).toFixed(2)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 3: Batch error handling
|
||||
const batchErrorHandling = await performanceTracker.measureAsync(
|
||||
'batch-error-handling',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
strategies: [],
|
||||
recommendation: null
|
||||
};
|
||||
|
||||
// Create batch with some invalid invoices
|
||||
const batchSize = 100;
|
||||
const errorRate = 0.2; // 20% errors
|
||||
|
||||
const testBatch = Array.from({ length: batchSize }, (_, i) => {
|
||||
const hasError = Math.random() < errorRate;
|
||||
|
||||
if (hasError) {
|
||||
return {
|
||||
id: i,
|
||||
invoice: {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
// Invalid invoice - missing required fields
|
||||
invoiceNumber: `ERROR-${i}`,
|
||||
items: []
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: i,
|
||||
invoice: {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `VALID-${i}`,
|
||||
issueDate: '2024-03-10',
|
||||
seller: { name: 'Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: '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 }
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Test different error handling strategies
|
||||
const strategies = [
|
||||
{
|
||||
name: 'Fail fast',
|
||||
fn: async (batch: any[]) => {
|
||||
const startTime = Date.now();
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
for (const item of batch) {
|
||||
const result = await einvoice.validateInvoice(item.invoice);
|
||||
if (!result.isValid) {
|
||||
throw new Error(`Validation failed for invoice ${item.id}`);
|
||||
}
|
||||
results.push({ id: item.id, success: true });
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
time: Date.now() - startTime,
|
||||
processed: results.length,
|
||||
failed: batch.length - results.length,
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
time: Date.now() - startTime,
|
||||
processed: results.length,
|
||||
failed: 0,
|
||||
results
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Continue on error',
|
||||
fn: async (batch: any[]) => {
|
||||
const startTime = Date.now();
|
||||
const results = [];
|
||||
let failed = 0;
|
||||
|
||||
for (const item of batch) {
|
||||
try {
|
||||
const result = await einvoice.validateInvoice(item.invoice);
|
||||
results.push({ id: item.id, success: result.isValid });
|
||||
if (!result.isValid) failed++;
|
||||
} catch (error) {
|
||||
results.push({ id: item.id, success: false, error: error.message });
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
time: Date.now() - startTime,
|
||||
processed: results.length,
|
||||
failed,
|
||||
results
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Parallel with error collection',
|
||||
fn: async (batch: any[]) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const promises = batch.map(async (item) => {
|
||||
try {
|
||||
const result = await einvoice.validateInvoice(item.invoice);
|
||||
return { id: item.id, success: result.isValid };
|
||||
} catch (error) {
|
||||
return { id: item.id, success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
const processed = results.filter(r => r.status === 'fulfilled').map(r => (r as any).value);
|
||||
const failed = processed.filter(r => !r.success).length;
|
||||
|
||||
return {
|
||||
time: Date.now() - startTime,
|
||||
processed: processed.length,
|
||||
failed,
|
||||
results: processed
|
||||
};
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
for (const strategy of strategies) {
|
||||
const result = await strategy.fn(testBatch);
|
||||
|
||||
results.strategies.push({
|
||||
name: strategy.name,
|
||||
time: result.time,
|
||||
processed: result.processed,
|
||||
failed: result.failed,
|
||||
successRate: ((result.processed - result.failed) / result.processed * 100).toFixed(2),
|
||||
throughput: (result.processed / (result.time / 1000)).toFixed(2)
|
||||
});
|
||||
}
|
||||
|
||||
// Determine best strategy
|
||||
results.recommendation = results.strategies.reduce((best, current) => {
|
||||
// Balance between completion and speed
|
||||
const bestScore = parseFloat(best.successRate) * parseFloat(best.throughput);
|
||||
const currentScore = parseFloat(current.successRate) * parseFloat(current.throughput);
|
||||
return currentScore > bestScore ? current.name : best.name;
|
||||
}, results.strategies[0].name);
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 4: Memory-efficient batch processing
|
||||
const memoryEfficientBatch = await performanceTracker.measureAsync(
|
||||
'memory-efficient-batch',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
approaches: [],
|
||||
memoryProfile: null
|
||||
};
|
||||
|
||||
// Create large dataset
|
||||
const totalItems = 1000;
|
||||
const createInvoice = (id: number) => ({
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `MEM-BATCH-${id}`,
|
||||
issueDate: '2024-03-10',
|
||||
seller: { name: `Memory Test Seller ${id}`, address: 'Long Address '.repeat(10), country: 'US', taxId: `US${id}` },
|
||||
buyer: { name: `Memory Test Buyer ${id}`, address: 'Long Address '.repeat(10), country: 'US', taxId: `US${id + 10000}` },
|
||||
items: Array.from({ length: 20 }, (_, j) => ({
|
||||
description: `Detailed product description for item ${j + 1} with lots of text `.repeat(5),
|
||||
quantity: j + 1,
|
||||
unitPrice: 100 + j,
|
||||
vatRate: 19,
|
||||
lineTotal: (j + 1) * (100 + j)
|
||||
})),
|
||||
totals: { netAmount: 0, vatAmount: 0, grossAmount: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
// Approach 1: Load all in memory
|
||||
const approach1 = async () => {
|
||||
if (global.gc) global.gc();
|
||||
const startMemory = process.memoryUsage();
|
||||
const startTime = Date.now();
|
||||
|
||||
// Create all invoices
|
||||
const allInvoices = Array.from({ length: totalItems }, (_, i) => createInvoice(i));
|
||||
|
||||
// Process all
|
||||
const results = await Promise.all(
|
||||
allInvoices.map(invoice => einvoice.validateInvoice(invoice))
|
||||
);
|
||||
|
||||
const endTime = Date.now();
|
||||
const endMemory = process.memoryUsage();
|
||||
|
||||
return {
|
||||
approach: 'Load all in memory',
|
||||
time: endTime - startTime,
|
||||
peakMemory: (endMemory.heapUsed - startMemory.heapUsed) / 1024 / 1024,
|
||||
processed: results.length,
|
||||
memoryPerItem: ((endMemory.heapUsed - startMemory.heapUsed) / 1024 / totalItems).toFixed(2)
|
||||
};
|
||||
};
|
||||
|
||||
// Approach 2: Streaming with chunks
|
||||
const approach2 = async () => {
|
||||
if (global.gc) global.gc();
|
||||
const startMemory = process.memoryUsage();
|
||||
const startTime = Date.now();
|
||||
const chunkSize = 50;
|
||||
let processed = 0;
|
||||
let peakMemory = 0;
|
||||
|
||||
for (let i = 0; i < totalItems; i += chunkSize) {
|
||||
// Create chunk on demand
|
||||
const chunk = Array.from(
|
||||
{ length: Math.min(chunkSize, totalItems - i) },
|
||||
(_, j) => createInvoice(i + j)
|
||||
);
|
||||
|
||||
// Process chunk
|
||||
await Promise.all(chunk.map(invoice => einvoice.validateInvoice(invoice)));
|
||||
processed += chunk.length;
|
||||
|
||||
// Track memory
|
||||
const currentMemory = process.memoryUsage();
|
||||
const memoryUsed = currentMemory.heapUsed - startMemory.heapUsed;
|
||||
if (memoryUsed > peakMemory) {
|
||||
peakMemory = memoryUsed;
|
||||
}
|
||||
|
||||
// Allow GC between chunks
|
||||
if (global.gc && i % 200 === 0) global.gc();
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
|
||||
return {
|
||||
approach: 'Streaming chunks',
|
||||
time: endTime - startTime,
|
||||
peakMemory: peakMemory / 1024 / 1024,
|
||||
processed,
|
||||
memoryPerItem: (peakMemory / 1024 / processed).toFixed(2)
|
||||
};
|
||||
};
|
||||
|
||||
// Approach 3: Generator-based processing
|
||||
const approach3 = async () => {
|
||||
if (global.gc) global.gc();
|
||||
const startMemory = process.memoryUsage();
|
||||
const startTime = Date.now();
|
||||
let processed = 0;
|
||||
let peakMemory = 0;
|
||||
|
||||
// Invoice generator
|
||||
function* invoiceGenerator() {
|
||||
for (let i = 0; i < totalItems; i++) {
|
||||
yield createInvoice(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Process using generator
|
||||
const batchSize = 20;
|
||||
const batch = [];
|
||||
|
||||
for (const invoice of invoiceGenerator()) {
|
||||
batch.push(einvoice.validateInvoice(invoice));
|
||||
|
||||
if (batch.length >= batchSize) {
|
||||
await Promise.all(batch);
|
||||
processed += batch.length;
|
||||
batch.length = 0;
|
||||
|
||||
// Track memory
|
||||
const currentMemory = process.memoryUsage();
|
||||
const memoryUsed = currentMemory.heapUsed - startMemory.heapUsed;
|
||||
if (memoryUsed > peakMemory) {
|
||||
peakMemory = memoryUsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process remaining
|
||||
if (batch.length > 0) {
|
||||
await Promise.all(batch);
|
||||
processed += batch.length;
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
|
||||
return {
|
||||
approach: 'Generator-based',
|
||||
time: endTime - startTime,
|
||||
peakMemory: peakMemory / 1024 / 1024,
|
||||
processed,
|
||||
memoryPerItem: (peakMemory / 1024 / processed).toFixed(2)
|
||||
};
|
||||
};
|
||||
|
||||
// Execute approaches
|
||||
results.approaches.push(await approach1());
|
||||
results.approaches.push(await approach2());
|
||||
results.approaches.push(await approach3());
|
||||
|
||||
// Analyze memory efficiency
|
||||
const sortedByMemory = [...results.approaches].sort((a, b) => a.peakMemory - b.peakMemory);
|
||||
const sortedBySpeed = [...results.approaches].sort((a, b) => a.time - b.time);
|
||||
|
||||
results.memoryProfile = {
|
||||
mostMemoryEfficient: sortedByMemory[0].approach,
|
||||
fastest: sortedBySpeed[0].approach,
|
||||
recommendation: sortedByMemory[0].peakMemory < sortedBySpeed[0].peakMemory * 0.5 ?
|
||||
'Use memory-efficient approach for large datasets' :
|
||||
'Use fastest approach if memory is not constrained'
|
||||
};
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 5: Corpus batch processing
|
||||
const corpusBatchProcessing = await performanceTracker.measureAsync(
|
||||
'corpus-batch-processing',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
totalFiles: files.length,
|
||||
batchResults: [],
|
||||
overallStats: {
|
||||
totalProcessed: 0,
|
||||
totalTime: 0,
|
||||
failures: 0,
|
||||
avgBatchTime: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Process corpus in batches
|
||||
const batchSize = 20;
|
||||
const maxBatches = 5; // Limit for testing
|
||||
const startTime = Date.now();
|
||||
|
||||
for (let batchNum = 0; batchNum < maxBatches && batchNum * batchSize < files.length; batchNum++) {
|
||||
const batchStart = batchNum * batchSize;
|
||||
const batchFiles = files.slice(batchStart, batchStart + batchSize);
|
||||
|
||||
const batchStartTime = Date.now();
|
||||
const batchResults = {
|
||||
batchNumber: batchNum + 1,
|
||||
filesInBatch: batchFiles.length,
|
||||
processed: 0,
|
||||
formats: new Map<string, number>(),
|
||||
errors: 0
|
||||
};
|
||||
|
||||
// Process batch in parallel
|
||||
const promises = batchFiles.map(async (file) => {
|
||||
try {
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
const format = await einvoice.detectFormat(content);
|
||||
|
||||
if (format && format !== 'unknown') {
|
||||
batchResults.formats.set(format, (batchResults.formats.get(format) || 0) + 1);
|
||||
|
||||
const invoice = await einvoice.parseInvoice(content, format);
|
||||
await einvoice.validateInvoice(invoice);
|
||||
|
||||
batchResults.processed++;
|
||||
return { success: true, format };
|
||||
} else {
|
||||
batchResults.errors++;
|
||||
return { success: false };
|
||||
}
|
||||
} catch (error) {
|
||||
batchResults.errors++;
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
const batchEndTime = Date.now();
|
||||
batchResults.batchTime = batchEndTime - batchStartTime;
|
||||
batchResults.throughput = (batchResults.processed / (batchResults.batchTime / 1000)).toFixed(2);
|
||||
|
||||
results.batchResults.push({
|
||||
...batchResults,
|
||||
formats: Array.from(batchResults.formats.entries())
|
||||
});
|
||||
|
||||
results.overallStats.totalProcessed += batchResults.processed;
|
||||
results.overallStats.failures += batchResults.errors;
|
||||
}
|
||||
|
||||
results.overallStats.totalTime = Date.now() - startTime;
|
||||
results.overallStats.avgBatchTime = results.batchResults.length > 0 ?
|
||||
results.batchResults.reduce((sum, b) => sum + b.batchTime, 0) / results.batchResults.length : 0;
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Summary
|
||||
t.comment('\n=== PERF-11: Batch Processing Test Summary ===');
|
||||
|
||||
t.comment('\nBatch Size Optimization:');
|
||||
t.comment(' Batch Size | Total Time | Processed | Throughput | Avg/Invoice | Avg/Batch');
|
||||
t.comment(' -----------|------------|-----------|------------|-------------|----------');
|
||||
batchSizeOptimization.result.batchSizes.forEach(size => {
|
||||
t.comment(` ${String(size.batchSize).padEnd(10)} | ${String(size.totalTime + 'ms').padEnd(10)} | ${String(size.processed).padEnd(9)} | ${size.throughput.padEnd(10)}/s | ${size.avgTimePerInvoice.padEnd(11)}ms | ${size.avgTimePerBatch}ms`);
|
||||
});
|
||||
t.comment(` Optimal batch size: ${batchSizeOptimization.result.optimalBatchSize} (${batchSizeOptimization.result.maxThroughput.toFixed(2)} ops/sec)`);
|
||||
|
||||
t.comment('\nBatch Operation Types:');
|
||||
batchOperationTypes.result.operations.forEach(op => {
|
||||
t.comment(` ${op.name}:`);
|
||||
t.comment(` - Avg time: ${op.avgTime}ms (${op.minTime}-${op.maxTime}ms)`);
|
||||
t.comment(` - Throughput: ${op.throughput} ops/sec`);
|
||||
t.comment(` - Per item: ${op.avgPerItem}ms`);
|
||||
});
|
||||
|
||||
t.comment('\nBatch Error Handling Strategies:');
|
||||
t.comment(' Strategy | Time | Processed | Failed | Success Rate | Throughput');
|
||||
t.comment(' --------------------------|--------|-----------|--------|--------------|----------');
|
||||
batchErrorHandling.result.strategies.forEach(strategy => {
|
||||
t.comment(` ${strategy.name.padEnd(25)} | ${String(strategy.time + 'ms').padEnd(6)} | ${String(strategy.processed).padEnd(9)} | ${String(strategy.failed).padEnd(6)} | ${strategy.successRate.padEnd(12)}% | ${strategy.throughput}/s`);
|
||||
});
|
||||
t.comment(` Recommended strategy: ${batchErrorHandling.result.recommendation}`);
|
||||
|
||||
t.comment('\nMemory-Efficient Batch Processing:');
|
||||
t.comment(' Approach | Time | Peak Memory | Processed | Memory/Item');
|
||||
t.comment(' -------------------|---------|-------------|-----------|------------');
|
||||
memoryEfficientBatch.result.approaches.forEach(approach => {
|
||||
t.comment(` ${approach.approach.padEnd(18)} | ${String(approach.time + 'ms').padEnd(7)} | ${approach.peakMemory.toFixed(2).padEnd(11)}MB | ${String(approach.processed).padEnd(9)} | ${approach.memoryPerItem}KB`);
|
||||
});
|
||||
t.comment(` Most memory efficient: ${memoryEfficientBatch.result.memoryProfile.mostMemoryEfficient}`);
|
||||
t.comment(` Fastest: ${memoryEfficientBatch.result.memoryProfile.fastest}`);
|
||||
t.comment(` ${memoryEfficientBatch.result.memoryProfile.recommendation}`);
|
||||
|
||||
t.comment('\nCorpus Batch Processing:');
|
||||
t.comment(` Total files: ${corpusBatchProcessing.result.totalFiles}`);
|
||||
t.comment(` Batches processed: ${corpusBatchProcessing.result.batchResults.length}`);
|
||||
t.comment(' Batch # | Files | Processed | Errors | Time | Throughput');
|
||||
t.comment(' --------|-------|-----------|--------|---------|----------');
|
||||
corpusBatchProcessing.result.batchResults.forEach(batch => {
|
||||
t.comment(` ${String(batch.batchNumber).padEnd(7)} | ${String(batch.filesInBatch).padEnd(5)} | ${String(batch.processed).padEnd(9)} | ${String(batch.errors).padEnd(6)} | ${String(batch.batchTime + 'ms').padEnd(7)} | ${batch.throughput}/s`);
|
||||
});
|
||||
t.comment(` Overall:`);
|
||||
t.comment(` - Total processed: ${corpusBatchProcessing.result.overallStats.totalProcessed}`);
|
||||
t.comment(` - Total failures: ${corpusBatchProcessing.result.overallStats.failures}`);
|
||||
t.comment(` - Total time: ${corpusBatchProcessing.result.overallStats.totalTime}ms`);
|
||||
t.comment(` - Avg batch time: ${corpusBatchProcessing.result.overallStats.avgBatchTime.toFixed(2)}ms`);
|
||||
|
||||
// Performance targets check
|
||||
t.comment('\n=== Performance Targets Check ===');
|
||||
const optimalThroughput = batchSizeOptimization.result.maxThroughput;
|
||||
const targetThroughput = 50; // Target: >50 ops/sec for batch processing
|
||||
|
||||
t.comment(`Batch throughput: ${optimalThroughput.toFixed(2)} ops/sec ${optimalThroughput > targetThroughput ? '✅' : '⚠️'} (target: >${targetThroughput} ops/sec)`);
|
||||
|
||||
// Overall performance summary
|
||||
t.comment('\n=== Overall Performance Summary ===');
|
||||
performanceTracker.logSummary();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.start();
|
688
test/suite/einvoice_performance/test.perf-12.resource-cleanup.ts
Normal file
688
test/suite/einvoice_performance/test.perf-12.resource-cleanup.ts
Normal file
@ -0,0 +1,688 @@
|
||||
/**
|
||||
* @file test.perf-12.resource-cleanup.ts
|
||||
* @description Performance tests for resource cleanup and management
|
||||
*/
|
||||
|
||||
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';
|
||||
import * as os from 'os';
|
||||
|
||||
const corpusLoader = new CorpusLoader();
|
||||
const performanceTracker = new PerformanceTracker('PERF-12: Resource Cleanup');
|
||||
|
||||
tap.test('PERF-12: Resource Cleanup - should properly manage and cleanup resources', async (t) => {
|
||||
// Test 1: Memory cleanup after operations
|
||||
const memoryCleanup = await performanceTracker.measureAsync(
|
||||
'memory-cleanup-after-operations',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
operations: [],
|
||||
cleanupEfficiency: null
|
||||
};
|
||||
|
||||
// Force initial GC to get baseline
|
||||
if (global.gc) global.gc();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const baselineMemory = process.memoryUsage();
|
||||
|
||||
// Test operations
|
||||
const operations = [
|
||||
{
|
||||
name: 'Large invoice processing',
|
||||
fn: async () => {
|
||||
const largeInvoices = Array.from({ length: 100 }, (_, i) => ({
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `CLEANUP-${i}`,
|
||||
issueDate: '2024-03-15',
|
||||
seller: {
|
||||
name: 'Large Data Seller ' + 'X'.repeat(1000),
|
||||
address: 'Long Address ' + 'Y'.repeat(1000),
|
||||
country: 'US',
|
||||
taxId: 'US123456789'
|
||||
},
|
||||
buyer: {
|
||||
name: 'Large Data Buyer ' + 'Z'.repeat(1000),
|
||||
address: 'Long Address ' + 'W'.repeat(1000),
|
||||
country: 'US',
|
||||
taxId: 'US987654321'
|
||||
},
|
||||
items: Array.from({ length: 50 }, (_, j) => ({
|
||||
description: `Item ${j} with very long description `.repeat(20),
|
||||
quantity: Math.random() * 100,
|
||||
unitPrice: Math.random() * 1000,
|
||||
vatRate: 19,
|
||||
lineTotal: 0
|
||||
})),
|
||||
totals: { netAmount: 0, vatAmount: 0, grossAmount: 0 }
|
||||
}
|
||||
}));
|
||||
|
||||
// Process all invoices
|
||||
for (const invoice of largeInvoices) {
|
||||
await einvoice.validateInvoice(invoice);
|
||||
await einvoice.convertFormat(invoice, 'cii');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'XML generation and parsing',
|
||||
fn: async () => {
|
||||
const xmlBuffers = [];
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `XML-GEN-${i}`,
|
||||
issueDate: '2024-03-15',
|
||||
seller: { name: 'XML Seller', address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: 'XML Buyer', address: 'Address', country: 'US', taxId: 'US456' },
|
||||
items: Array.from({ length: 100 }, (_, j) => ({
|
||||
description: `XML Item ${j}`,
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 19,
|
||||
lineTotal: 100
|
||||
})),
|
||||
totals: { netAmount: 10000, vatAmount: 1900, grossAmount: 11900 }
|
||||
}
|
||||
};
|
||||
|
||||
const xml = await einvoice.generateXML(invoice);
|
||||
xmlBuffers.push(Buffer.from(xml));
|
||||
|
||||
// Parse it back
|
||||
await einvoice.parseInvoice(xml, 'ubl');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Concurrent operations',
|
||||
fn: async () => {
|
||||
const promises = [];
|
||||
|
||||
for (let i = 0; i < 200; i++) {
|
||||
promises.push((async () => {
|
||||
const xml = `<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ID>CONCURRENT-${i}</ID></Invoice>`;
|
||||
const format = await einvoice.detectFormat(xml);
|
||||
const parsed = await einvoice.parseInvoice(xml, format || 'ubl');
|
||||
await einvoice.validateInvoice(parsed);
|
||||
})());
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Execute operations and measure cleanup
|
||||
for (const operation of operations) {
|
||||
// Memory before operation
|
||||
if (global.gc) global.gc();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const beforeOperation = process.memoryUsage();
|
||||
|
||||
// Execute operation
|
||||
await operation.fn();
|
||||
|
||||
// Memory after operation (before cleanup)
|
||||
const afterOperation = process.memoryUsage();
|
||||
|
||||
// Force cleanup
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// Memory after cleanup
|
||||
const afterCleanup = process.memoryUsage();
|
||||
|
||||
const memoryUsed = (afterOperation.heapUsed - beforeOperation.heapUsed) / 1024 / 1024;
|
||||
const memoryRecovered = (afterOperation.heapUsed - afterCleanup.heapUsed) / 1024 / 1024;
|
||||
const recoveryRate = memoryUsed > 0 ? (memoryRecovered / memoryUsed * 100) : 0;
|
||||
|
||||
results.operations.push({
|
||||
name: operation.name,
|
||||
memoryUsedMB: memoryUsed.toFixed(2),
|
||||
memoryRecoveredMB: memoryRecovered.toFixed(2),
|
||||
recoveryRate: recoveryRate.toFixed(2),
|
||||
finalMemoryMB: ((afterCleanup.heapUsed - baselineMemory.heapUsed) / 1024 / 1024).toFixed(2),
|
||||
externalMemoryMB: ((afterCleanup.external - baselineMemory.external) / 1024 / 1024).toFixed(2)
|
||||
});
|
||||
}
|
||||
|
||||
// Overall cleanup efficiency
|
||||
const totalUsed = results.operations.reduce((sum, op) => sum + parseFloat(op.memoryUsedMB), 0);
|
||||
const totalRecovered = results.operations.reduce((sum, op) => sum + parseFloat(op.memoryRecoveredMB), 0);
|
||||
|
||||
results.cleanupEfficiency = {
|
||||
totalMemoryUsedMB: totalUsed.toFixed(2),
|
||||
totalMemoryRecoveredMB: totalRecovered.toFixed(2),
|
||||
overallRecoveryRate: totalUsed > 0 ? (totalRecovered / totalUsed * 100).toFixed(2) : '0',
|
||||
memoryLeakDetected: results.operations.some(op => parseFloat(op.finalMemoryMB) > 10)
|
||||
};
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 2: File handle cleanup
|
||||
const fileHandleCleanup = await performanceTracker.measureAsync(
|
||||
'file-handle-cleanup',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
tests: [],
|
||||
handleLeaks: false
|
||||
};
|
||||
|
||||
// Monitor file handles (platform-specific)
|
||||
const getOpenFiles = () => {
|
||||
try {
|
||||
if (process.platform === 'linux') {
|
||||
const { execSync } = require('child_process');
|
||||
const pid = process.pid;
|
||||
const output = execSync(`ls /proc/${pid}/fd 2>/dev/null | wc -l`).toString();
|
||||
return parseInt(output.trim());
|
||||
}
|
||||
return -1; // Not supported on this platform
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
const initialHandles = getOpenFiles();
|
||||
|
||||
// Test scenarios
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'Sequential file operations',
|
||||
fn: async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const sampleFiles = files.slice(0, 20);
|
||||
|
||||
for (const file of sampleFiles) {
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
const format = await einvoice.detectFormat(content);
|
||||
|
||||
if (format && format !== 'unknown') {
|
||||
const invoice = await einvoice.parseInvoice(content, format);
|
||||
await einvoice.validateInvoice(invoice);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Concurrent file operations',
|
||||
fn: async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const sampleFiles = files.slice(0, 20);
|
||||
|
||||
await Promise.all(sampleFiles.map(async (file) => {
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
const format = await einvoice.detectFormat(content);
|
||||
|
||||
if (format && format !== 'unknown') {
|
||||
const invoice = await einvoice.parseInvoice(content, format);
|
||||
await einvoice.validateInvoice(invoice);
|
||||
}
|
||||
}));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Large file streaming',
|
||||
fn: async () => {
|
||||
// Create temporary large file
|
||||
const tempFile = '/tmp/einvoice-test-large.xml';
|
||||
const largeContent = '<?xml version="1.0"?><Invoice>' + 'X'.repeat(1024 * 1024) + '</Invoice>';
|
||||
|
||||
await plugins.fs.writeFile(tempFile, largeContent);
|
||||
|
||||
try {
|
||||
// Read in chunks
|
||||
const chunkSize = 64 * 1024;
|
||||
const fd = await plugins.fs.open(tempFile, 'r');
|
||||
const buffer = Buffer.alloc(chunkSize);
|
||||
let position = 0;
|
||||
|
||||
while (true) {
|
||||
const { bytesRead } = await fd.read(buffer, 0, chunkSize, position);
|
||||
if (bytesRead === 0) break;
|
||||
position += bytesRead;
|
||||
}
|
||||
|
||||
await fd.close();
|
||||
} finally {
|
||||
// Cleanup
|
||||
await plugins.fs.unlink(tempFile).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Execute scenarios
|
||||
for (const scenario of scenarios) {
|
||||
const beforeHandles = getOpenFiles();
|
||||
|
||||
await scenario.fn();
|
||||
|
||||
// Allow time for handle cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const afterHandles = getOpenFiles();
|
||||
|
||||
results.tests.push({
|
||||
name: scenario.name,
|
||||
beforeHandles: beforeHandles === -1 ? 'N/A' : beforeHandles,
|
||||
afterHandles: afterHandles === -1 ? 'N/A' : afterHandles,
|
||||
handleIncrease: beforeHandles === -1 || afterHandles === -1 ? 'N/A' : afterHandles - beforeHandles
|
||||
});
|
||||
}
|
||||
|
||||
// Check for handle leaks
|
||||
const finalHandles = getOpenFiles();
|
||||
if (initialHandles !== -1 && finalHandles !== -1) {
|
||||
results.handleLeaks = finalHandles > initialHandles + 5; // Allow small variance
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 3: Event listener cleanup
|
||||
const eventListenerCleanup = await performanceTracker.measureAsync(
|
||||
'event-listener-cleanup',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
listenerTests: [],
|
||||
memoryLeaks: false
|
||||
};
|
||||
|
||||
// Test event emitter scenarios
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'Proper listener removal',
|
||||
fn: async () => {
|
||||
const emitter = new EventEmitter();
|
||||
const listeners = [];
|
||||
|
||||
// Add listeners
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const listener = () => {
|
||||
// Process invoice event
|
||||
einvoice.validateInvoice({
|
||||
format: 'ubl',
|
||||
data: { invoiceNumber: `EVENT-${i}` }
|
||||
});
|
||||
};
|
||||
|
||||
listeners.push(listener);
|
||||
emitter.on('invoice', listener);
|
||||
}
|
||||
|
||||
// Trigger events
|
||||
for (let i = 0; i < 10; i++) {
|
||||
emitter.emit('invoice');
|
||||
}
|
||||
|
||||
// Remove listeners
|
||||
for (const listener of listeners) {
|
||||
emitter.removeListener('invoice', listener);
|
||||
}
|
||||
|
||||
return {
|
||||
listenersAdded: listeners.length,
|
||||
listenersRemaining: emitter.listenerCount('invoice')
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Once listeners',
|
||||
fn: async () => {
|
||||
const emitter = new EventEmitter();
|
||||
let triggeredCount = 0;
|
||||
|
||||
// Add once listeners
|
||||
for (let i = 0; i < 100; i++) {
|
||||
emitter.once('process', () => {
|
||||
triggeredCount++;
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger event
|
||||
emitter.emit('process');
|
||||
|
||||
return {
|
||||
listenersAdded: 100,
|
||||
triggered: triggeredCount,
|
||||
listenersRemaining: emitter.listenerCount('process')
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Memory pressure with listeners',
|
||||
fn: async () => {
|
||||
const emitter = new EventEmitter();
|
||||
const startMemory = process.memoryUsage().heapUsed;
|
||||
|
||||
// Add many listeners with closures
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const largeData = Buffer.alloc(1024); // 1KB per listener
|
||||
|
||||
emitter.on('data', () => {
|
||||
// Closure captures largeData
|
||||
return largeData.length;
|
||||
});
|
||||
}
|
||||
|
||||
const afterAddMemory = process.memoryUsage().heapUsed;
|
||||
|
||||
// Remove all listeners
|
||||
emitter.removeAllListeners('data');
|
||||
|
||||
// Force GC
|
||||
if (global.gc) global.gc();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const afterRemoveMemory = process.memoryUsage().heapUsed;
|
||||
|
||||
return {
|
||||
memoryAddedMB: ((afterAddMemory - startMemory) / 1024 / 1024).toFixed(2),
|
||||
memoryFreedMB: ((afterAddMemory - afterRemoveMemory) / 1024 / 1024).toFixed(2),
|
||||
listenersRemaining: emitter.listenerCount('data')
|
||||
};
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Execute scenarios
|
||||
for (const scenario of scenarios) {
|
||||
const result = await scenario.fn();
|
||||
results.listenerTests.push({
|
||||
name: scenario.name,
|
||||
...result
|
||||
});
|
||||
}
|
||||
|
||||
// Check for memory leaks
|
||||
const memoryTest = results.listenerTests.find(t => t.name === 'Memory pressure with listeners');
|
||||
if (memoryTest) {
|
||||
const freed = parseFloat(memoryTest.memoryFreedMB);
|
||||
const added = parseFloat(memoryTest.memoryAddedMB);
|
||||
results.memoryLeaks = freed < added * 0.8; // Should free at least 80%
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 4: Long-running operation cleanup
|
||||
const longRunningCleanup = await performanceTracker.measureAsync(
|
||||
'long-running-cleanup',
|
||||
async () => {
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
iterations: 0,
|
||||
memorySnapshots: [],
|
||||
stabilized: false,
|
||||
trend: null
|
||||
};
|
||||
|
||||
// Simulate long-running process
|
||||
const testDuration = 10000; // 10 seconds
|
||||
const snapshotInterval = 1000; // Every second
|
||||
const startTime = Date.now();
|
||||
const startMemory = process.memoryUsage();
|
||||
|
||||
let iteration = 0;
|
||||
const snapshotTimer = setInterval(() => {
|
||||
const memory = process.memoryUsage();
|
||||
results.memorySnapshots.push({
|
||||
time: Date.now() - startTime,
|
||||
heapUsedMB: (memory.heapUsed / 1024 / 1024).toFixed(2),
|
||||
externalMB: (memory.external / 1024 / 1024).toFixed(2),
|
||||
iteration
|
||||
});
|
||||
}, snapshotInterval);
|
||||
|
||||
// Continuous operations
|
||||
while (Date.now() - startTime < testDuration) {
|
||||
// Create and process invoice
|
||||
const invoice = {
|
||||
format: 'ubl' as const,
|
||||
data: {
|
||||
documentType: 'INVOICE',
|
||||
invoiceNumber: `LONG-RUN-${iteration}`,
|
||||
issueDate: '2024-03-15',
|
||||
seller: { name: `Seller ${iteration}`, address: 'Address', country: 'US', taxId: 'US123' },
|
||||
buyer: { name: `Buyer ${iteration}`, address: 'Address', country: 'US', taxId: 'US456' },
|
||||
items: Array.from({ length: 10 }, (_, i) => ({
|
||||
description: `Item ${i}`,
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
vatRate: 19,
|
||||
lineTotal: 100
|
||||
})),
|
||||
totals: { netAmount: 1000, vatAmount: 190, grossAmount: 1190 }
|
||||
}
|
||||
};
|
||||
|
||||
await einvoice.validateInvoice(invoice);
|
||||
await einvoice.convertFormat(invoice, 'cii');
|
||||
|
||||
iteration++;
|
||||
results.iterations = iteration;
|
||||
|
||||
// Periodic cleanup
|
||||
if (iteration % 50 === 0 && global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
|
||||
// Small delay to prevent CPU saturation
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
clearInterval(snapshotTimer);
|
||||
|
||||
// Analyze memory trend
|
||||
if (results.memorySnapshots.length >= 5) {
|
||||
const firstHalf = results.memorySnapshots.slice(0, Math.floor(results.memorySnapshots.length / 2));
|
||||
const secondHalf = results.memorySnapshots.slice(Math.floor(results.memorySnapshots.length / 2));
|
||||
|
||||
const avgFirstHalf = firstHalf.reduce((sum, s) => sum + parseFloat(s.heapUsedMB), 0) / firstHalf.length;
|
||||
const avgSecondHalf = secondHalf.reduce((sum, s) => sum + parseFloat(s.heapUsedMB), 0) / secondHalf.length;
|
||||
|
||||
results.trend = {
|
||||
firstHalfAvgMB: avgFirstHalf.toFixed(2),
|
||||
secondHalfAvgMB: avgSecondHalf.toFixed(2),
|
||||
increasing: avgSecondHalf > avgFirstHalf * 1.1,
|
||||
stable: Math.abs(avgSecondHalf - avgFirstHalf) < avgFirstHalf * 0.1
|
||||
};
|
||||
|
||||
results.stabilized = results.trend.stable;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Test 5: Corpus cleanup verification
|
||||
const corpusCleanupVerification = await performanceTracker.measureAsync(
|
||||
'corpus-cleanup-verification',
|
||||
async () => {
|
||||
const files = await corpusLoader.getFilesByPattern('**/*.xml');
|
||||
const einvoice = new EInvoice();
|
||||
const results = {
|
||||
phases: [],
|
||||
overallCleanup: null
|
||||
};
|
||||
|
||||
// Process corpus in phases
|
||||
const phases = [
|
||||
{ name: 'Initial batch', count: 50 },
|
||||
{ name: 'Heavy processing', count: 100 },
|
||||
{ name: 'Final batch', count: 50 }
|
||||
];
|
||||
|
||||
if (global.gc) global.gc();
|
||||
const initialMemory = process.memoryUsage();
|
||||
|
||||
for (const phase of phases) {
|
||||
const phaseStart = process.memoryUsage();
|
||||
const startTime = Date.now();
|
||||
|
||||
// Process files
|
||||
const phaseFiles = files.slice(0, phase.count);
|
||||
let processed = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const file of phaseFiles) {
|
||||
try {
|
||||
const content = await plugins.fs.readFile(file, 'utf-8');
|
||||
const format = await einvoice.detectFormat(content);
|
||||
|
||||
if (format && format !== 'unknown') {
|
||||
const invoice = await einvoice.parseInvoice(content, format);
|
||||
await einvoice.validateInvoice(invoice);
|
||||
|
||||
// Heavy processing for middle phase
|
||||
if (phase.name === 'Heavy processing') {
|
||||
await einvoice.convertFormat(invoice, 'cii');
|
||||
await einvoice.generateXML(invoice);
|
||||
}
|
||||
|
||||
processed++;
|
||||
}
|
||||
} catch (error) {
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
const phaseEnd = process.memoryUsage();
|
||||
|
||||
// Cleanup between phases
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
const afterCleanup = process.memoryUsage();
|
||||
|
||||
results.phases.push({
|
||||
name: phase.name,
|
||||
filesProcessed: processed,
|
||||
errors,
|
||||
duration: Date.now() - startTime,
|
||||
memoryUsedMB: ((phaseEnd.heapUsed - phaseStart.heapUsed) / 1024 / 1024).toFixed(2),
|
||||
memoryAfterCleanupMB: ((afterCleanup.heapUsed - phaseStart.heapUsed) / 1024 / 1024).toFixed(2),
|
||||
cleanupEfficiency: ((phaseEnd.heapUsed - afterCleanup.heapUsed) / (phaseEnd.heapUsed - phaseStart.heapUsed) * 100).toFixed(2)
|
||||
});
|
||||
}
|
||||
|
||||
// Final cleanup
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
const finalMemory = process.memoryUsage();
|
||||
|
||||
results.overallCleanup = {
|
||||
initialMemoryMB: (initialMemory.heapUsed / 1024 / 1024).toFixed(2),
|
||||
finalMemoryMB: (finalMemory.heapUsed / 1024 / 1024).toFixed(2),
|
||||
totalIncreaseMB: ((finalMemory.heapUsed - initialMemory.heapUsed) / 1024 / 1024).toFixed(2),
|
||||
acceptableIncrease: (finalMemory.heapUsed - initialMemory.heapUsed) < 50 * 1024 * 1024 // Less than 50MB
|
||||
};
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Summary
|
||||
t.comment('\n=== PERF-12: Resource Cleanup Test Summary ===');
|
||||
|
||||
t.comment('\nMemory Cleanup After Operations:');
|
||||
t.comment(' Operation | Used | Recovered | Recovery % | Final | External');
|
||||
t.comment(' -------------------------|---------|-----------|------------|---------|----------');
|
||||
memoryCleanup.result.operations.forEach(op => {
|
||||
t.comment(` ${op.name.padEnd(24)} | ${op.memoryUsedMB.padEnd(7)}MB | ${op.memoryRecoveredMB.padEnd(9)}MB | ${op.recoveryRate.padEnd(10)}% | ${op.finalMemoryMB.padEnd(7)}MB | ${op.externalMemoryMB}MB`);
|
||||
});
|
||||
t.comment(` Overall efficiency:`);
|
||||
t.comment(` - Total used: ${memoryCleanup.result.cleanupEfficiency.totalMemoryUsedMB}MB`);
|
||||
t.comment(` - Total recovered: ${memoryCleanup.result.cleanupEfficiency.totalMemoryRecoveredMB}MB`);
|
||||
t.comment(` - Recovery rate: ${memoryCleanup.result.cleanupEfficiency.overallRecoveryRate}%`);
|
||||
t.comment(` - Memory leak detected: ${memoryCleanup.result.cleanupEfficiency.memoryLeakDetected ? 'YES ⚠️' : 'NO ✅'}`);
|
||||
|
||||
t.comment('\nFile Handle Cleanup:');
|
||||
fileHandleCleanup.result.tests.forEach(test => {
|
||||
t.comment(` ${test.name}:`);
|
||||
t.comment(` - Before: ${test.beforeHandles}, After: ${test.afterHandles}`);
|
||||
t.comment(` - Handle increase: ${test.handleIncrease}`);
|
||||
});
|
||||
t.comment(` Handle leaks detected: ${fileHandleCleanup.result.handleLeaks ? 'YES ⚠️' : 'NO ✅'}`);
|
||||
|
||||
t.comment('\nEvent Listener Cleanup:');
|
||||
eventListenerCleanup.result.listenerTests.forEach(test => {
|
||||
t.comment(` ${test.name}:`);
|
||||
if (test.listenersAdded !== undefined) {
|
||||
t.comment(` - Added: ${test.listenersAdded}, Remaining: ${test.listenersRemaining}`);
|
||||
}
|
||||
if (test.memoryAddedMB !== undefined) {
|
||||
t.comment(` - Memory added: ${test.memoryAddedMB}MB, Freed: ${test.memoryFreedMB}MB`);
|
||||
}
|
||||
});
|
||||
t.comment(` Memory leaks in listeners: ${eventListenerCleanup.result.memoryLeaks ? 'YES ⚠️' : 'NO ✅'}`);
|
||||
|
||||
t.comment('\nLong-Running Operation Cleanup:');
|
||||
t.comment(` Iterations: ${longRunningCleanup.result.iterations}`);
|
||||
t.comment(` Memory snapshots: ${longRunningCleanup.result.memorySnapshots.length}`);
|
||||
if (longRunningCleanup.result.trend) {
|
||||
t.comment(` Memory trend:`);
|
||||
t.comment(` - First half avg: ${longRunningCleanup.result.trend.firstHalfAvgMB}MB`);
|
||||
t.comment(` - Second half avg: ${longRunningCleanup.result.trend.secondHalfAvgMB}MB`);
|
||||
t.comment(` - Trend: ${longRunningCleanup.result.trend.increasing ? 'INCREASING ⚠️' : longRunningCleanup.result.trend.stable ? 'STABLE ✅' : 'DECREASING ✅'}`);
|
||||
}
|
||||
t.comment(` Memory stabilized: ${longRunningCleanup.result.stabilized ? 'YES ✅' : 'NO ⚠️'}`);
|
||||
|
||||
t.comment('\nCorpus Cleanup Verification:');
|
||||
t.comment(' Phase | Files | Duration | Memory Used | After Cleanup | Efficiency');
|
||||
t.comment(' -------------------|-------|----------|-------------|---------------|------------');
|
||||
corpusCleanupVerification.result.phases.forEach(phase => {
|
||||
t.comment(` ${phase.name.padEnd(18)} | ${String(phase.filesProcessed).padEnd(5)} | ${String(phase.duration + 'ms').padEnd(8)} | ${phase.memoryUsedMB.padEnd(11)}MB | ${phase.memoryAfterCleanupMB.padEnd(13)}MB | ${phase.cleanupEfficiency}%`);
|
||||
});
|
||||
t.comment(` Overall cleanup:`);
|
||||
t.comment(` - Initial memory: ${corpusCleanupVerification.result.overallCleanup.initialMemoryMB}MB`);
|
||||
t.comment(` - Final memory: ${corpusCleanupVerification.result.overallCleanup.finalMemoryMB}MB`);
|
||||
t.comment(` - Total increase: ${corpusCleanupVerification.result.overallCleanup.totalIncreaseMB}MB`);
|
||||
t.comment(` - Acceptable increase: ${corpusCleanupVerification.result.overallCleanup.acceptableIncrease ? 'YES ✅' : 'NO ⚠️'}`);
|
||||
|
||||
// Performance targets check
|
||||
t.comment('\n=== Performance Targets Check ===');
|
||||
const memoryRecoveryRate = parseFloat(memoryCleanup.result.cleanupEfficiency.overallRecoveryRate);
|
||||
const targetRecoveryRate = 80; // Target: >80% memory recovery
|
||||
const noMemoryLeaks = !memoryCleanup.result.cleanupEfficiency.memoryLeakDetected &&
|
||||
!fileHandleCleanup.result.handleLeaks &&
|
||||
!eventListenerCleanup.result.memoryLeaks &&
|
||||
longRunningCleanup.result.stabilized;
|
||||
|
||||
t.comment(`Memory recovery rate: ${memoryRecoveryRate}% ${memoryRecoveryRate > targetRecoveryRate ? '✅' : '⚠️'} (target: >${targetRecoveryRate}%)`);
|
||||
t.comment(`Resource leak prevention: ${noMemoryLeaks ? 'PASSED ✅' : 'FAILED ⚠️'}`);
|
||||
|
||||
// Overall performance summary
|
||||
t.comment('\n=== Overall Performance Summary ===');
|
||||
performanceTracker.logSummary();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.start();
|
Reference in New Issue
Block a user