DEV Community

eliasfischer8351
eliasfischer8351

Posted on

PDF Endpoints for SaaS Identity Verification: How to Bound Go Latency in 3 Stages

For a US/EU education SaaS that fills and flattens enrollment PDFs during customer identity verification, use the least complex design that keeps request latency independent of PDF work: a synchronous intake contract, an asynchronous processing contract, and a controlled artifact-retrieval contract. Put validation and idempotency at intake, do form filling and flattening behind a bounded queue, and release an output only after its audit manifest is committed. This division protects latency under load without pretending that fidelity, privacy, and operational complexity can all be maximized at once.

The bill starts with bytes retained over time, then adds page processing, transfer, and request orchestration. Write those terms down before selecting an endpoint. For a hypothetical batch of 10,000 packets averaging 8 MB, raw arrival is 80 GB per batch; retaining a source, a same-sized flattened result, and one same-sized scratch copy creates 240 GB before replicas or backups. If the batch arrives during a one-hour enrollment deadline, moving flattening into the intake request does nothing to reduce those bytes and forces request capacity to track the burst. A queue changes the capacity problem: intake can acknowledge durable acceptance while a fixed worker pool drains the 10,000 operations, and operators can calculate completion time from observed service time rather than an HTTP timeout. The retention change is separate. Removing the scratch copy after reconciliation cuts that example's live artifact footprint from 240 GB to 160 GB, although replicas, backups, manifests, and logs still need their own accounting. That saving has an operational consequence: after scratch deletion, an investigator can compare the accepted source digest with the committed output record, but can't inspect an intermediate rendering state. The team must decide whether reproducible replay is enough before deleting it. This arithmetic doesn't prove storage is always the dominant cost; it exposes the term that retention policy can move immediately and the evidence sacrificed to move it.

Don't optimize a price sheet first. Measure retained byte-days, processed pages, transferred bytes, queue wait, and active CPU time on representative forms; whichever term dominates that workload deserves the first engineering change. I'm not sure which term will dominate an unseen document mix, and neither is anyone else without those measurements.

How should a US/EU SaaS choose PDF endpoints for customer identity verification?

Choose endpoint capabilities by lifecycle boundary, not by a vendor's route vocabulary. The intake capability accepts document bytes plus a tenant-scoped idempotency key, checks size and declared media type, records a content digest, and returns an immutable operation identifier. It must not flatten the PDF inside the request deadline. A processing capability consumes the accepted operation, applies field data to the correct form revision, flattens the result, and commits a manifest. A retrieval capability returns only the committed artifact to an authorized caller and records the access.

Contract Work allowed on the critical path Main control
Intake Validate, digest, and durably accept Idempotency key
Processing Fill, flatten, verify, and commit Bounded concurrency
Retrieval Authorize and return committed output Access audit

That separation answers the latency question. The intake path has a bounded amount of work; the queue absorbs bursts; processing concurrency protects memory and CPU; retrieval can't observe a half-written output. It also makes a provider-specific collection of PDF endpoints replaceable behind three application contracts. Keep provider method names and polling details in the adapter. Keep identity policy, audit events, and tenant authorization in your service.

Keep intake cheap.

For browser upload code, treat a Blob as immutable raw data and convert it to an ArrayBuffer only when the client genuinely needs byte access. Sending the blob directly avoids an unnecessary client-side representation change. The Blob API is a browser primitive, however, not a retention policy or an authorization boundary.

The capability split is deliberately small. A callback can reduce status polling, but it adds signature verification, replay protection, delivery retries, and another externally reachable surface. Stick with polling when job volume is modest and the extra callback machinery would be harder to operate than the traffic it removes. Prefer a callback only after measurements show that polling load matters and the team can own its audit trail.

Quantify throughput before adding workers

Batch throughput is constrained by the slowest sustainable stage, while user-visible latency is queue wait plus processing plus commit time. Let lambda be arriving packets per second, s be measured mean processing seconds per packet, and c be concurrent workers. A necessary steady-state condition is lambda * s < c; equality leaves no room for variance, retries, or maintenance. This isn't a latency guarantee. It is the first test that prevents an obviously unstable deployment.

The queue is the shock absorber.

Use tail measurements rather than a single mean when setting the intake deadline and the concurrency cap. PDFs with the same byte size can have different page counts, form structures, embedded resources, and output sizes, so byte size alone is a poor proxy for processing time. Partitioning by a measured work class, such as page-count bands, can stop a long packet from occupying every worker lane, but each extra queue increases scheduling and observability complexity. Start with one bounded queue and split it only when traces show head-of-line blocking.

The following program is a runnable model of the processing boundary. It admits each operation once, limits concurrency to three workers, and records a digest in the committed manifest. The render function stands in for a standards-aware PDF engine selected through fidelity tests; it doesn't claim that copying bytes performs real form flattening.

package main

import (
    "context"
    "crypto/sha256"
    "errors"
    "fmt"
    "sync"
    "time"
)

type Job struct {
    ID       string
    TenantID string
    Source   []byte
}

type Manifest struct {
    JobID        string
    SourceDigest [32]byte
    CommittedAt  time.Time
}

type Ledger struct {
    mu        sync.Mutex
    committed map[string]Manifest
}

func (l *Ledger) Commit(job Job, output []byte) (Manifest, error) {
    l.mu.Lock()
    defer l.mu.Unlock()
    if prior, ok := l.committed[job.ID]; ok {
        return prior, nil
    }
    if len(output) == 0 {
        return Manifest{}, errors.New("empty output")
    }
    m := Manifest{
        JobID:        job.ID,
        SourceDigest: sha256.Sum256(job.Source),
        CommittedAt:  time.Now().UTC(),
    }
    l.committed[job.ID] = m
    return m, nil
}

func render(ctx context.Context, source []byte) ([]byte, error) {
    select {
    case <-ctx.Done():
        return nil, ctx.Err()
    default:
        return append([]byte(nil), source...), nil
    }
}

func worker(ctx context.Context, jobs <-chan Job, ledger *Ledger, wg *sync.WaitGroup) {
    defer wg.Done()
    for job := range jobs {
        output, err := render(ctx, job.Source)
        if err != nil {
            fmt.Printf("job=%s state=retryable reason=%q\n", job.ID, err)
            continue
        }
        manifest, err := ledger.Commit(job, output)
        if err != nil {
            fmt.Printf("job=%s state=rejected reason=%q\n", job.ID, err)
            continue
        }
        fmt.Printf("job=%s committed=%s\n", manifest.JobID, manifest.CommittedAt.Format(time.RFC3339))
    }
}

func main() {
    ctx := context.Background()
    jobs := make(chan Job, 6)
    ledger := &Ledger{committed: make(map[string]Manifest)}

    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go worker(ctx, jobs, ledger, &wg)
    }
    jobs <- Job{ID: "enrollment-0042", TenantID: "school-17", Source: []byte("sample")}
    jobs <- Job{ID: "enrollment-0042", TenantID: "school-17", Source: []byte("sample")}
    close(jobs)
    wg.Wait()
}
Enter fullscreen mode Exit fullscreen mode

The duplicate job is intentional: the ledger returns the prior manifest rather than creating a second logical result. A production store needs a unique constraint on the tenant and idempotency key, a transaction that couples state transition with manifest commit, and an outbox or equivalent durable handoff for downstream events. Exactly-once delivery isn't a property of a queue; exactly-once business effect is an application invariant built from deduplication, transactions, and replayable evidence.

Test fidelity as an acceptance contract

"Looks right" is not a useful acceptance criterion for an identity packet. Build a corpus from forms your organization is permitted to use, covering each supported template revision, and define observable checks before choosing any processing endpoint: required values survive filling, flattened fields are no longer editable in the intended workflow, page count is unchanged unless explicitly allowed, text and signatures remain in their expected regions, and the output can be opened by every reader environment the product supports. A visual comparison can catch rendering drift, while structural inspection catches a field that still exists even though the pixels appear correct.

Run the corpus against every engine or service under consideration with identical inputs. Record output digest, output size, processing duration, engine version, template revision, and verdict. Those records form an audit trail; they don't establish that a document is legally sufficient or that a particular identity-verification control meets every US or EU obligation. Compliance approval remains a separate review tied to jurisdiction, data category, purpose, retention schedule, and contractual role.

Fail closed.

If an output misses an acceptance rule, quarantine that operation and preserve the source plus manifest according to policy; don't silently ship the closest rendering. This choice lowers nominal throughput during bad-input bursts, yet it protects the property the pipeline exists to deliver.

There is a catch: high-fidelity self-hosted processing gives a team direct control over versions, isolation, and capacity, but it also assigns that team patching, sandboxing, font management, scaling, and incident response. A managed asynchronous capability transfers part of that operational burden, while adding an external data processor, network latency, and adapter work. A synchronous remote operation may be suitable for small interactive documents with demonstrated tail latency, but it is not suitable when batch bursts can consume the entire request budget. In that case, keep the asynchronous boundary even if the underlying engine is local.

Make retries boring and deletion explicit

Every transition should be reconstructable from append-only evidence: intake accepted, processing leased, output committed, retrieval authorized, and artifacts deleted. Log identifiers and digests rather than document contents or extracted identity values. Bind an idempotency key to tenant, operation type, input digest, and template revision so that reusing a key with different input is rejected instead of ambiguously overwriting history.

Retries must resume from a durable state. A worker lease may expire and be reassigned, but the final commit still passes through the unique business key. Use bounded attempts for inputs that repeatedly fail validation, expose queue age separately from execution duration, and alert on the oldest eligible job rather than relying on average latency. Three minutes of average queue time can conceal one packet that has waited three hours.

Retention completes the cost argument. Keep the original, committed output, and compact manifest only for the approved period; delete transient uploads, renderer scratch files, and abandoned partial outputs as soon as reconciliation proves they are no longer needed. What you deliberately stop keeping is per-stage scratch state. The price of that decision appears during an investigation: engineers can prove which accepted source produced which committed artifact, but they cannot inspect every temporary renderer intermediate and may need to replay the source in an isolated environment. If policy forbids retaining the source long enough for replay, the audit manifest must be sufficient to explain the state transition without reconstructing document content.

The practical decision is therefore conditional. Use the three-stage contract when batch throughput and latency isolation matter; collapse it only when measured volume is low, documents are small and predictable, and the simpler synchronous failure model is more valuable than burst absorption. Pick the PDF engine through corpus results, pick concurrency through arrival and service measurements, and pick retention through approved purpose and investigative need. No endpoint label can make those decisions for the system owner.

References

Top comments (0)