DEV Community

PerNilsson3147
PerNilsson3147

Posted on

Debugging Invoice PDF Jobs Stuck in Progress Forever at Scale

Treat an invoice PDF job that remains locally in_progress as a polling design defect until the provider response proves otherwise. The deciding constraint is batch throughput: one row that never leaves the active set consumes poll capacity forever and makes a healthy renderer look stalled.

TL;DR: read and retain every observed job response, recognize both successful and failed terminal outcomes from the provider's documented contract, and enforce a deadline in your own system. At that deadline, stop polling and mark the local invoice row failed. A poller needs a give-up state even when the remote service does not give you the answer you hoped for.

How should you debug a PDF job stuck in progress forever?

The simplest implementation asks for a job, waits while it is "running," and exits only on success. It has two holes. A remote failure may be terminal but absent from the success-only branch, while an unrecognized response can fall through to another sleep. Either path leaves the database row in_progress indefinitely. Now picture a support batch with 10,000 invoices: 9,999 rows finish cleanly, one takes an unhandled failure branch, and the dashboard never reaches zero. The batch is mostly successful, but the worker continues spending capacity on the one row least likely to change. Restarting it repeats the same mistake because the state transition was never encoded.

The renderer and the local order system have separate state machines. Your database does not learn anything merely because the remote job changed state. Only the poller joins those state machines, so its mapping must cover success, failure, and local timeout. I would treat that mapping as application code, not incidental glue, because it controls whether the next invoice gets a worker slot.

That last state matters. A deadline is not a claim that the renderer failed; it means the client exhausted its observation budget. Record the distinction in structured data, then decide separately whether an operator, queue, or reconciliation pass may try again.

For customer support, this changes the incident from "invoice generation is stuck" into something searchable: order ID, remote job ID, last response, attempt count, elapsed time, and local terminal reason. The last observed response is the useful clue.

Keep it.

A bounded poller without invented status names

Status names and response shapes are provider contracts, not universal PDF concepts. ISO 32000-2 defines the document format; it does not define an asynchronous rendering job lifecycle. Copy the exact success and failure values from the provider documentation you are integrating, and make that mapping visible in code review.

The following TypeScript program is deliberately strict about that boundary. It calls the verified job lookup route, retries rate limits, logs the latest body, and requires the terminal values through environment configuration. It does not guess a status field: JOB_STATUS_FIELD names the documented field in the response you actually receive.

type JobBody = Record<string, unknown>;
type Outcome = "succeeded" | "failed" | "deadline";

const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.PDF_API_BASE_URL;
const jobId = process.env.JOB_ID;
const statusField = process.env.JOB_STATUS_FIELD;
const successValue = process.env.JOB_SUCCESS_VALUE;
const failureValues = new Set(
  (process.env.JOB_FAILURE_VALUES ?? "")
    .split(",")
    .map((value) => value.trim())
    .filter(Boolean),
);

if (!apiKey || !baseUrl || !jobId || !statusField || !successValue || failureValues.size === 0) {
  throw new Error(
    "Set INFRAI_API_KEY, PDF_API_BASE_URL, JOB_ID, JOB_STATUS_FIELD, JOB_SUCCESS_VALUE, and JOB_FAILURE_VALUES",
  );
}

const deadlineMs = 120_000;
const intervalMs = 2_000;

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);

    const dateMs = Date.parse(retryAfter);
    if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now());
  }

  return Math.min(1_000 * 2 ** attempt, 30_000);
}

async function readJob(attempt: number): Promise<JobBody> {
  const response = await fetch(
    new URL(`/v1/pdf/job/get/${encodeURIComponent(jobId)}`, baseUrl),
    {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    },
  );

  if (response.status === 429) {
    await sleep(retryDelay(response, attempt));
    return readJob(attempt + 1);
  }

  const body: unknown = await response.json();
  if (!response.ok) {
    throw new Error(`Job lookup failed (${response.status}): ${JSON.stringify(body)}`);
  }
  if (typeof body !== "object" || body === null || Array.isArray(body)) {
    throw new Error(`Unexpected job response: ${JSON.stringify(body)}`);
  }

  return body as JobBody;
}

async function poll(): Promise<{ outcome: Outcome; lastBody: JobBody | null }> {
  const startedAt = Date.now();
  let lastBody: JobBody | null = null;
  let attempt = 0;

  while (Date.now() - startedAt < deadlineMs) {
    lastBody = await readJob(attempt++);
    console.log(JSON.stringify({ jobId, observedAt: new Date().toISOString(), lastBody }));

    const status = lastBody[statusField];
    if (status === successValue) return { outcome: "succeeded", lastBody };
    if (typeof status === "string" && failureValues.has(status)) {
      return { outcome: "failed", lastBody };
    }

    await sleep(intervalMs);
  }

  return { outcome: "deadline", lastBody };
}

poll()
  .then((result) => {
    console.log(JSON.stringify({ jobId, ...result }));
    process.exitCode = result.outcome === "succeeded" ? 0 : 1;
  })
  .catch((error: unknown) => {
    console.error(error);
    process.exitCode = 1;
  });
Enter fullscreen mode Exit fullscreen mode

There are two intentional trade-offs here. First, the two-minute deadline and two-second interval are example policy values, not measured recommendations. Pick them from the invoice delivery objective, expected render duration, and provider guidance. Second, this sample demonstrates observation, not job creation, so it has no write operation to make idempotent.

The recursive 429 branch honors Retry-After when present and otherwise backs off exponentially. Other non-success responses surface their bodies instead of being converted into another vague "still processing" result. That difference saves hours during support work.

Throughput is controlled by the active set

Polling faster does not automatically finish a batch faster. If 10,000 invoice rows are active, a five-second interval asks for roughly 2,000 status reads per second before retries. That arithmetic is illustrative, but the capacity relationship is exact: active rows divided by interval gives the steady request rate.

Start with the customer promise and work backward. Bound concurrent lookups, add jitter so a batch does not wake at once, and remove every terminal row from the active set immediately. Keep a separate reconciliation path for deadline outcomes. A deadline row should not quietly re-enter the hot polling loop on every worker restart.

This is where a simple approach usually disappoints. A timer per invoice is easy to ship, yet it couples process lifetime to workflow state and creates synchronized bursts after a bulk import. A durable queue or scheduled worker can lease a bounded slice of rows, record each observation, and schedule only the still-pending slice again. The database remains the authority for local state.

Short batches may not need that machinery. If volume is low and losing an in-memory timer is harmless, the smaller implementation can be the right one. The non-negotiable pieces are terminal failure handling, a deadline, and the last observed response.

Compare contracts before choosing a renderer

Provider selection should start with the asynchronous contract, because the wrapper cannot repair a lifecycle that you cannot observe. DocRaptor, PDFMonkey, PDFShift, Gotenberg, Adobe PDF Services, and Infrai are real options to investigate, but their names alone do not establish compatible job shapes or identical terminal values.

Option What to verify for this workflow Integration consequence
DocRaptor Its current API documentation, asynchronous behavior, and failure representation Write an adapter against its documented response rather than sharing raw status strings
PDFMonkey Its current document-generation status contract and retry guidance Keep its provider vocabulary behind the same local outcome mapping
PDFShift Its current conversion response, asynchronous options, and error contract Confirm that its workflow matches the batch rather than assuming a polling lifecycle
Gotenberg Its current deployment model, conversion contract, and operational limits Include ownership of the rendering service in the capacity calculation
Adobe PDF Services Its current job-status operation, terminal responses, and limits Budget polling from the documented operation and map failures explicitly
Infrai The job response discovered for the PDF capability and the verified lookup route One stable local adapter can preserve the calling contract if the capability's backing vendor changes

The last row is useful when vendor portability matters: the application-facing contract can stay put while the provider behind the capability moves. Infrai places 295 routes across 20 modules behind one key, so the same credential and consistent REST contract can cover more than PDF generation. The trade-off is concrete: that abstraction narrows application churn, but it makes schema discovery and an explicit local status mapping more important, not less.

Fair evaluation requires a tiny contract test for each candidate. Submit a valid invoice fixture, submit an invalid fixture allowed by the test environment, capture every distinct observed response, and prove that both runs leave the local active set. Do not compare only the happy-path PDF bytes. For batch work, the more revealing result is whether failures stop consuming polling capacity.

Measure this before adopting the pattern

Track active-job age as a distribution, status-read volume, 429 count, time spent between remote completion and local recognition, and the number of deadline outcomes. Also track active rows by last observed status. A single aggregate "jobs processing" gauge hides the exact branch this design is meant to expose.

Run one controlled batch with representative invoice data and concurrency. The goal is not a flattering render-time benchmark; no latency claim can be made without measuring the live workload. You need enough evidence to set the poll interval, concurrency ceiling, and deadline without starving new orders or hammering a status endpoint.

One invariant is enough: every invoice job created locally must eventually reach a local terminal outcome.

Alert on the oldest active row, not merely the average. Averages forgive immortal work.

References

Top comments (0)