TL;DR: Treat PDF conversion as an evidence-producing transaction, not a file utility. A Node.js API can accept the work and expose job state, while an isolated worker converts the invoice, validates the requested artifact, records hashes and policy versions, and signs a canonical manifest. Release the derivative only after those steps succeed. This boundary matters more than the particular conversion engine.
For a marketplace invoice, “the PNG came out” is too weak a success condition. The useful result is a tuple: the immutable source, the derivative, the conversion policy, and evidence that binds them together. Keep that tuple under one job identifier, make retries converge on it, and never overwrite a completed result in place.
No evidence, no release.
How should Node.js convert PDF formats for downstream processing?
Start the architecture decision record with invariants. The source PDF is immutable. The requested target format is allowlisted. Every accepted output can be traced to one exact source digest and one exact policy version. A consumer cannot observe a completed job before validation and evidence recording finish. A retry for the same source, target, and policy either returns the existing result or produces the same logical result without creating a second invoice event.
The failure boundary belongs around the whole transaction. A converter process can exit successfully and still leave no expected file, a zero-byte file, or an artifact of the wrong media type. Storage can accept the derivative while the audit write fails. A signature operation can time out after conversion. None of those states should become complete.
That's the gate.
This is where messaging instincts transfer well. An accepted send is not delivered mail, and an accepted conversion is not a usable downstream artifact. The handoff must have its own proof.
Use explicit states such as accepted, converting, validating, attesting, complete, and failed. Keep internal failure detail out of customer-visible invoice documents, but preserve a stable error class and correlation identifier in operational records. Short-lived retries need jitter and a cap; invalid input needs no retry at all.
Record the decision before choosing an engine
There are three defensible boundaries. The right one depends on who must verify the evidence and how much isolation the workload needs.
| Boundary | Signature and audit consequence | Failure isolation | Valid use |
|---|---|---|---|
| In-process conversion | Application and converter share one failure and deployment boundary | Lowest | Small, trusted inputs with a narrow, controlled format set |
| Local worker process | The application can hash inputs and outputs around a separate execution step | Process-level | Teams that operate their own conversion runtime |
| Remote conversion service | Requests, responses, and downloaded artifacts need explicit correlation and hashing | Network and service boundary | Workloads that require independent scaling or runtime isolation |
I'd choose the worker boundary for this marketplace flow because invoice generation, conversion, and evidence retention have different operational concerns. That is a trade-off, not a universal ranking. It adds a queue and another deployable unit, but it keeps CPU- and memory-heavy document handling away from the Node.js request loop and gives the audit transaction a clear owner.
The Node.js service should validate metadata, persist the idempotency key, enqueue a reference to the immutable PDF, and return the job identifier. It should not pass a large document through several JSON messages or mark the job complete based only on a worker exit code. The worker owns the critical path below.
Build the critical path around a canonical manifest
The conversion command should be configuration, not business logic. An adapter can invoke a locally managed executable or a remote implementation, while the transaction code stays fixed. This Python example is deliberately small enough to test: it copies a supplied PDF into an isolated directory, runs an injected converter, verifies that an artifact exists, hashes both files, serializes a canonical manifest, and asks an injected signer to sign those exact bytes.
from __future__ import annotations
import hashlib
import json
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Sequence
ALLOWED_TARGETS = {"png", "txt"}
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def canonical_bytes(value: dict[str, str]) -> bytes:
return json.dumps(
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode("ascii")
@dataclass(frozen=True)
class EvidenceBundle:
artifact: bytes
manifest: bytes
signature: bytes
def convert_invoice(
source_pdf: Path,
target: str,
policy_version: str,
converter_command: Callable[[Path, Path, str], Sequence[str]],
signer: Callable[[bytes], bytes],
) -> EvidenceBundle:
if source_pdf.suffix.lower() != ".pdf":
raise ValueError("source must have a .pdf extension")
if target not in ALLOWED_TARGETS:
raise ValueError(f"unsupported target: {target}")
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
local_source = work / "source.pdf"
local_source.write_bytes(source_pdf.read_bytes())
output = work / f"invoice.{target}"
subprocess.run(
converter_command(local_source, output, target),
check=True,
timeout=120,
cwd=work,
)
if not output.is_file() or output.stat().st_size == 0:
raise RuntimeError("converter produced no non-empty artifact")
manifest_data = {
"policy_version": policy_version,
"source_sha256": sha256(local_source),
"target_format": target,
"artifact_sha256": sha256(output),
}
manifest = canonical_bytes(manifest_data)
return EvidenceBundle(output.read_bytes(), manifest, signer(manifest))
The signer is intentionally an interface. Its key identifier, algorithm, and verification material belong in the stored evidence envelope, according to the trust model selected by the organization. Do not claim that a plain hash is a signature. A digest detects a changed byte sequence only when the expected digest is itself protected; the signature binds the canonical manifest to the configured signing authority.
The example also avoids timestamps inside the signed core. Time still belongs in the append-only audit event, but excluding it from the deterministic job identity makes retry behavior easier to reason about. Store an attempt timestamp separately from the content-derived manifest.
The sample uses a 120-second process deadline and reads source data in 1 MiB chunks. Those are visible starting points, not universal production limits: set them from the document-size policy and resource envelope, then test the boundary values. I prefer an explicit cap that fails closed over a converter process with an unbounded lifetime, even though the cap creates a new retry case.
Validate what the downstream system actually consumes
Existence and nonzero length are the floor. Validation must follow the target. For raster output, decode every image and enforce dimension and page-count policy. For extracted text, decode with the declared character encoding, check the expected page coverage, and preserve ordering rules required by the consumer. If the downstream step uses invoice totals, compare structured order data with the extraction result instead of trusting visual similarity.
PDF is standardized by ISO 32000-2, but format conformance alone cannot prove that a marketplace invoice contains the right order, tax, seller, or buyer data. Those are application invariants. Keep them in a versioned validation policy so a future rule change does not silently reinterpret old evidence.
Sensitive fields deserve the same care as OTP payloads: log identifiers and digests, not document contents. A useful audit event records the job identifier, tenant or marketplace partition, source and artifact digests, requested target, policy version, signer key identifier, outcome, stable error class, and causal attempt identifier. Retention and access controls should apply to the source, derivative, manifest, signature, and logs as one evidence set.
Keep the invoice out of logs.
Test the awkward edges. Include an encrypted PDF, a truncated source, an empty output, a multi-page invoice, a converter timeout, a duplicate delivery, a signature timeout, and an audit-store rejection. Also test a successful retry after each transient boundary. The assertion is not merely “eventually complete”; it is “at most one released evidence set for one deterministic job identity.”
Operationally, measure queue age, conversion duration, validation failures by stable class, signature latency, retry count, and incomplete evidence sets. Avoid customer data in metric labels. Alerting on growing queue age usually reveals a capacity or dependency problem earlier than an aggregate failure counter.
Why reject direct synchronous conversion?
The rejected option is conversion inside the request handler. It is attractive because it removes queue plumbing and appears to make error handling linear. For small trusted files, low traffic, and a consumer that can safely retry the whole request, it remains a valid design.
It is a poor fit for marketplace invoice evidence. Request cancellation, proxy deadlines, converter resource use, and signature latency become one coupled deadline. The client also has to decide whether a lost response means “nothing happened” or “the artifact exists.” An idempotent asynchronous job makes that ambiguity explicit and gives each stage an observable state.
That ambiguity is expensive to investigate.
Deployment should preserve the same separation. Pin and record the conversion runtime through the policy version, roll it out to a small worker pool, and compare validation outcomes before broadening the rollout. Keep old workers available long enough to finish their claimed jobs. A rollback must not overwrite artifacts already attested under the newer policy.
The final decision rule is narrow: release a converted invoice only when its derivative, validation result, canonical manifest, signature, and audit event agree on the same job identity. Everything else is intermediate state.
References
- ISO 32000-2, Portable Document Format: https://www.iso.org/standard/75839.html
Top comments (0)