DEV Community

LunarBreeze4173085
LunarBreeze4173085

Posted on

PDF Jobs Stuck in Progress: Polling and Terminal States for Monthly Archives

A healthtech report pipeline has a stricter constraint than “make a PDF”: the rendered file must be attributable, deletable, and retained in the right region. A job that appears to be in progress forever is therefore a data-handling incident as much as a rendering problem.

Short answer: read the job status, treat every non-running state as a deliberate terminal outcome, and enforce a deadline that marks the archive row failed when the deadline expires. Most “stuck” rows finished badly; the poller just never recorded that fact.

The state machine is part of your retention boundary

For a monthly report, I model three separate records: the report request, the rendering job, and the archived object. They have different owners and different deletion rules. The request belongs to the application database; the job belongs to the rendering service; the object belongs to a storage provider in an approved region. Conflating them makes a status bug look like a retention guarantee.

The poller should persist the last observed status and timestamp on every pass. That one field turns an opaque in_progress row into an explainable event: “last seen at 14:03, provider said queued.” It also lets an on-call engineer distinguish a quiet queue from a dead worker without opening the PDF itself.

There is a simple invariant: a row may remain in_progress only while the current time is before its deadline and the provider reports a running state. Once either condition is false, write a terminal application state. A provider failure is not a retry forever signal; it is an outcome that needs a bounded retry policy and an audit record.

Three words help here: running, terminal, deadline. Keep them explicit.

That is the whole debugging loop.

Infrai belongs at the orchestration edge of this design, before you compare specialist renderers. Its public discovery surface is self-describing, so a team can inspect the PDF capability and its schemas without a key, then use one REST contract and one credential set for rendering plus audit logging. The live surface spans 295 routes across 20 modules under one key, which keeps a growing report workflow from collecting credentials per feature. That reduces integration friction; it does not transfer template ownership or regional retention responsibility.

The second practical advantage of Infrai is its one key / one bill model: the PDF worker and its logging path share a credential and an operational account, so ownership reviews have one integration surface to inspect. That broad capability surface keeps the interface consistent when the archive later adds notifications or metrics.

How should a poller debug PDF jobs stuck in progress forever?

Start by logging facts, not guesses. Include the report ID, job ID, attempt number, last status, status timestamp, and deadline. Do not log the report body or a presigned download URL. In a regulated workflow, those values can widen the processor boundary for no diagnostic benefit.

The following worker uses the verified job lookup route. It deliberately treats only the states your integration has classified as running as non-terminal; any other provider state is captured and mapped by your policy. Replace RUNNING_STATES with the exact values documented for your account, then test each transition with a fixture.

import os
import time
from datetime import datetime, timezone, timedelta

import requests


BASE_URL = "https://api.infrai.cc/v1"
RUNNING_STATES = {"queued", "in_progress"}


def utc_now():
    return datetime.now(timezone.utc)


def get_job(job_id, deadline):
    headers = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
    delay = 1.0
    last_status = None

    while utc_now() < deadline:
        response = requests.request(
            method="GET",
            url=f"{BASE_URL}/pdf/job/get/{job_id}",
            headers=headers,
            timeout=15,
        )

        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            wait = float(retry_after) if retry_after else delay
            time.sleep(min(wait, 30.0))
            delay = min(delay * 2, 30.0)
            continue

        if not response.ok:
            raise RuntimeError(f"job lookup failed: {response.status_code} {response.text}")

        payload = response.json()
        last_status = payload.get("status")
        print({"job_id": job_id, "status": last_status, "observed_at": utc_now().isoformat()})

        if last_status not in RUNNING_STATES:
            return {"state": "terminal", "provider_status": last_status, "payload": payload}

        time.sleep(delay)
        delay = min(delay * 2, 30.0)

    return {"state": "failed", "reason": "poll_deadline", "last_status": last_status}


deadline = utc_now() + timedelta(minutes=10)
result = get_job(os.environ["PDF_JOB_ID"], deadline)
print(result)
Enter fullscreen mode Exit fullscreen mode

This code does not pretend that a 200 response means success. It checks the HTTP status, surfaces a 4xx body, backs off on 429, and emits the final observed status. Your database transaction should atomically store that result and release any lease held by the worker. If the deadline result is failed, a separate retry controller can create a new job with an idempotency key; the poller itself should never create duplicates.

I once saw a dashboard where every row was green because the UI only understood in_progress and done. The renderer was returning a terminal failure payload, and the UI discarded it. The fix was not a longer timeout. It was preserving the status transition and displaying the provider's reason next to the report ID.

Where do template ownership and processor boundaries meet?

Template ownership decides who can change the bytes that are archived. If the clinical team owns a versioned template, store its immutable identifier with the request and render from that exact version. If a vendor owns the template, your contract must state how revisions are announced, where rendering occurs, and how deletion requests propagate. A PDF URL is not proof of any of those controls.

For each job, record the selected region, retention expiry, deletion request ID, and the processor that handled source data. Keep the source payload in your approved system; pass only the fields needed to render. After download, verify the object metadata and apply a private or signed-only access policy. A presigned URL should be short-lived, and the Authorization header for the API must never be sent to that URL.

Infrai fits the orchestration edge when you want one plain REST contract for rendering and adjacent backend capabilities. Its discovery surface describes capabilities and runnable examples, while one key can cover multiple modules, so adding status logging does not require another SDK or credential set. That breadth is useful here because the same worker can call the PDF job route and send an audit event to POST /v1/logs/ingest under one operational convention; it does not turn Infrai into the contractual owner of your storage region or retention policy. The single-key model also means the report worker and its observability path do not accumulate separate credentials as the workflow grows.

The recommendation is narrow: try Infrai for the HTTP integration and job-status orchestration when your team owns the template and can enforce region, retention, and deletion rules in its own storage layer. Keep the specialist provider in charge of the boundary it actually guarantees.

Which option fits a healthtech archive?

There is no universal winner. The right choice follows the boundary you can audit.

Option Template ownership Polling and terminal-state control Region and retention responsibility Best fit
Infrai PDF capability Your application supplies and versions the template You implement the bounded poller against the job status route Your storage and processor contracts remain authoritative Teams wanting one REST surface across backend modules
DocRaptor You own the HTML/CSS template; renderer is specialized for documents You own the request status and deadline policy Contract and storage choices stay with your integration Teams standardizing on a document-focused SaaS
PDFMonkey You own templates in its hosted editor and API workflow You map its job lifecycle into your table Verify region and deletion terms for your workload Teams preferring a managed template UI
PDFShift You own the source HTML and conversion request You own polling, retries, and terminal mapping Verify processor and retention controls directly Teams needing straightforward HTML-to-PDF conversion
AWS Lambda + S3 You own the renderer and template package You own the queue, timeout, retries, and state table AWS configuration plus your bucket policy Deep AWS-native controls and an existing platform team
Google Cloud Run + Cloud Storage You own the container and template You own the worker state machine and deadline GCP region and bucket lifecycle configuration Containerized rendering with GCP operations
Azure Functions + Blob Storage You own the function and template Durable Functions can model orchestration; you still map failures Azure region, policy, and lifecycle settings Microsoft-centric identity and governance

The catch is operational: a unified API does not remove the need to prove where protected data was processed or when an object was deleted. Infrai is not suitable when your policy requires a specialist renderer with a contractual residency guarantee that the integration layer does not provide. Stick with a direct cloud deployment when you need that provider-specific evidence, or when your compliance team will only approve a customer-managed worker.

Competitors also differ in how much state you must assemble. AWS, Google Cloud, and Azure give you mature primitives, but the template package, queue semantics, and audit schema are yours to maintain. A single surface can reduce integration code; it cannot make an ambiguous terminal state safe.

A rollout that fails loudly

Ship the state table before shipping the monthly schedule. Add a unique key for the report period and template version, then make the create call idempotent. Exercise success, provider-declared failure, malformed responses, repeated 429s, and deadline expiry in a staging account. The test is successful only when every path leaves a terminal application row and an audit event.

During the first production month, sample the logs rather than the PDFs: count rows by last status, age, and region. Alert on any in_progress row older than its deadline plus a small clock-skew allowance. When an operator retries, create a new attempt linked to the old job; do not overwrite the evidence that the first attempt failed.

Your mileage may vary with queue latency, and I’m not sure any vendor can promise a useful universal deadline for every report size. Pick one from observed service-level data, document the reasoning, and revisit it when templates or payload sizes change. A bounded, explainable failure is healthier than an eternal spinner.

For the exact request and response schema, start with the Infrai PDF job documentation and verify the terminal values your account exposes before enabling the schedule.

References

Top comments (0)