Skip to main content
Introducing packages.sweber.dev
Documentation menuValidating many invoices

Validating many invoices

validateMany validates folders and archives of invoices with a bounded pool of worker threads in Node.js - streaming results in input order, errors per file, measured speed-up.

validateInvoice is synchronous and validates one invoice on the calling thread. For a folder with thousands of invoices, validateMany from the subpath @sweberdev/summand/batch spreads the work over a bounded pool of worker threads, returns the results in input order and never throws for a single file. Available since 1.1.0.

import { validateMany } from "@sweberdev/summand/batch";

for await (const { index, name, result, error } of validateMany(files, { lang: "de" })) {
  if (error) console.error(`${name}: could not be processed: ${error.message}`);
  else if (!result.valid) console.log(`${name}: ${result.errors.length} error(s)`);
}

Node.js only. The module uses worker_threads and reads files from disk, so it does not run in the browser or on edge runtimes; see In the browser. It is a separate entry point, so the main bundle does not carry it.

Inputs

validateMany(inputs, options?) takes any iterable or async iterable. An entry is either:

EntryMeaning
a stringThe path of a file (UBL or CII XML, or a ZUGFeRD / Factur-X PDF). The worker reads it, so file contents never travel through your main thread. A string is never treated as XML text.
{ name, data }Content you already have: data is XML text, bytes or an ArrayBuffer (anything validateInvoice accepts). name labels the result. The data is copied to the worker.

Inputs are pulled lazily, at most twice concurrency ahead of the consumer. A directory listing, a generator or a stream of archive entries therefore never has to fit in memory:

import { readdir } from "node:fs/promises";
import { join } from "node:path";

async function* xmlFiles(dir: string) {
  for (const name of await readdir(dir)) if (name.endsWith(".xml")) yield join(dir, name);
}

for await (const r of validateMany(xmlFiles("invoices/"))) { /* ... */ }

Results

Every input gives exactly one BatchResult:

FieldContent
indexPosition of the input, starting at 0
nameThe path, or the name you gave
resultThe ValidationResult of validateInvoice (set unless error is set). An invalid invoice is a result with result.valid === false.
error{ code: "SUM-BATCH", message }, set instead of result when the input could not be processed: the file cannot be read (missing, a directory, no permission), the data could not be sent to the worker, or the worker failed. See SUM-BATCH.

Results arrive in input order, whichever file finishes first. The same input gives the same sequence for every concurrency, which makes runs comparable and reports reproducible. A slow file delays the results behind it, not the work: the other workers keep going up to the window of twice concurrency results.

Nothing is thrown per input. validateMany throws synchronously only for a concurrency that is not a positive integer (RangeError), and the iteration rejects when the signal aborts or when your own input iterable throws.

Streaming or array

const run = validateMany(files);

// streaming: handle each result as soon as it is ready (in order)
for await (const r of run) report(r);

// or collect everything (the whole batch is held in memory)
const all = await validateMany(files).toArray();

A run can be consumed once. Leaving the for await loop early (break, return, an exception) terminates the workers; nothing keeps running in the background.

Options

validateMany takes every option of validateInvoice (lang, ruleSets, schema, includeXml, ...), applied to every input, plus:

OptionDefaultEffect
concurrencymin(4, number of CPU cores)How many invoices are validated at the same time, one worker thread each. Workers are started when there is work for them, and a worker that dies is replaced. With 1, the invoices are validated one after another on the calling thread and no worker is started (useful for debugging and where worker threads are not available).
signalnoneAn AbortSignal. When it aborts, pending inputs are dropped, workers are terminated and the iteration rejects with the signal's reason.

The default is capped at 4 because validation is memory-hungry, see the measurements below; set a higher value if you have measured that it helps on your machine. Options are copied to the workers, so they must be plain data (no functions).

Exit codes and reports

A script that wraps validateMany should do what summand validate does: exit 1 when some invoice is invalid, and 2 when some input has an error. An unreadable file must not look like a valid one.

let invalid = 0;
let failed = 0;
for await (const r of validateMany(files)) {
  if (r.error) failed++;
  else if (!r.result.valid) invalid++;
}
process.exitCode = failed ? 2 : invalid ? 1 : 0;

How much faster

It depends on the invoices and, more than you would expect, on the machine. Measured numbers, the method and a control experiment are in Performance. In short, on our 4-core test container: about 1.5x for 1,000-line invoices, at most 1.26x for 100-line invoices, and slower than a plain loop for batches of small invoices, because each worker has to start and warm up. The speed-up is limited by memory bandwidth, not only by the number of cores.

In the browser

There is no browser version of validateMany: browsers have no worker_threads and no file system access by path. Call validateInvoice for each file instead. For many files, use a few Web Workers yourself, each running validateInvoice (see In the browser), and keep the number of workers small for the reason above.