A page fires at 02:14: the marketplace's external-sharing queue is aging past its service-level objective, while request latency, CPU, and error rate still look ordinary. The on-call sees 38 waiting documents, one active worker, and no clue whether the active item has one page left or 400. The incident has explained the architecture problem rather bluntly: document rendering belongs in a background job. Restarting it may discard expensive work; adding workers may exhaust memory. The alert arrived after the useful decision point.
TL;DR: watermarking a document before external sharing belongs in a durable background job because the input does not reveal the work. Page count is an output of layout, not a trustworthy input to capacity planning. Accept the upload quickly, persist the template version and watermark policy, render under a bounded worker pool, and expose progress plus a terminal result. The least complex design that meets this requirement is a queue, idempotent workers, durable artifacts, and one status record per job.
This is not an argument that every PDF operation needs a queue. Stamping a known, already paginated PDF can have a tight workload envelope. Rendering a marketplace agreement from seller data, attachments, fonts, and a template does not. Treating both paths as a synchronous request hides the uncertainty exactly where an SRE needs to see it. The limitation of the job design is added state, queue operations, polling, and eventual completion; for a bounded stamp on a small, pre-paginated input, a synchronous operation is the simpler alternative.
Why should document rendering be a background job when page counts vary?
Pagination happens after several interacting inputs have been resolved. A longer legal name can wrap a line and push a signature block onto another page. A missing font can change glyph metrics. A seller-provided image can alter a fixed-height region if the template's sizing rule permits it. An attachment may already contain hundreds of pages. A watermark that includes an external recipient identifier may fit at one rotation and clip at another. None of those examples requires exotic software; they follow from asking layout to turn content and constraints into pages.
Consider three hypothetical marketplace shares. They are examples for planning, not benchmark results:
| Share request | What the API knows at acceptance | What rendering can discover | Operational consequence |
|---|---|---|---|
| One-page tax form | template ID, seller fields, recipient mark | a fallback font wraps an address onto page 2 | a nominally tiny task crosses a page boundary |
| Seller contract plus exhibits | contract fields, three uploaded exhibits | the exhibits contain 4, 19, and 212 pages | attachment inspection dominates the estimate |
| Product compliance packet | 86 records, image references, policy revision | several images need decoding and the table splits repeatedly | record count is not a page-count proxy |
The point is not that page count is random. It is deterministic only after the rendering engine has the exact inputs, font set, layout rules, attachment bytes, and template revision. Before then, an estimate is a hint. If the HTTP deadline, retry policy, or worker memory limit treats that hint as a promise, an ordinary long document becomes an incident.
Template ownership sharpens this distinction. When the platform team owns templates, it can constrain fonts, image boxes, overflow behavior, and maximum attachment size, then test those rules before deployment. When business teams or sellers own templates, change velocity improves but the workload envelope widens; the platform must validate aggressively and isolate execution. A managed rendering service can transfer engine maintenance and some scaling work, while self-hosting can offer tighter control over templates, fonts, data locality, and upgrade timing. Neither removes pagination uncertainty.
Work backward from the late alert
Queue age should have fired before the external-sharing SLO was consumed. Queue depth alone is weak: 38 one-page forms and 38 compliance packets are different backlogs. Request latency is weaker still because a correctly asynchronous acceptance endpoint can remain fast while work accumulates behind it.
I would instrument four moments: accepted, render started, each durable progress checkpoint, and terminal success or failure. Record the immutable template revision and an input class at acceptance. Once the renderer knows the true page total, publish it; until then, keep it unknown instead of inventing a zero or an estimate that dashboards silently treat as fact. The useful early signal is oldest-job age against an explicit budget, sliced by input class and template revision. Completion rate and active-worker saturation explain whether the queue is recovering.
This changes the page from “the queue is large” to “the oldest standard contract has spent 72% of its latency budget waiting, revision 184 is overrepresented, and all worker slots are occupied.” The numbers in that sentence are illustrative fields, not claimed production measurements. They show what an actionable page should answer.
The instrumentation boundary can stay small. This Go sketch records state transitions without pretending the total is known before layout:
package renderjob
import (
"context"
"fmt"
"time"
)
type Metrics interface {
ObserveQueueWait(templateRevision string, wait time.Duration)
SetPagesKnown(jobID string, pages int)
CountResult(templateRevision, result string)
}
type Renderer interface {
Render(ctx context.Context, input Input) (Artifact, error)
}
type Input struct {
JobID string
TemplateRevision string
AcceptedAt time.Time
}
type Artifact struct {
URI string
Pages int
}
func Run(ctx context.Context, in Input, r Renderer, m Metrics) (Artifact, error) {
m.ObserveQueueWait(in.TemplateRevision, time.Since(in.AcceptedAt))
artifact, err := r.Render(ctx, in)
if err != nil {
m.CountResult(in.TemplateRevision, "failed")
return Artifact{}, fmt.Errorf("render job %s: %w", in.JobID, err)
}
m.SetPagesKnown(in.JobID, artifact.Pages)
m.CountResult(in.TemplateRevision, "succeeded")
return artifact, nil
}
In a real worker, the terminal update and artifact publication need a consistent protocol: use an idempotency key, write to a unique temporary object, verify the result, then publish the durable location and terminal state. A retry must not create two externally shareable artifacts with different watermarks. Cancellation also needs a state of its own; “the caller stopped polling” is not cancellation.
Make template ownership an operating decision
The platform roadmap question is not “queue or no queue.” Once the work exceeds a request budget, the queue is table stakes. The harder decision is who may change the layout program and who carries the resulting pager load.
| Model | Change control | Capacity-planning effect | On-call cost | Lock-in pressure |
|---|---|---|---|---|
| Platform-owned templates, self-hosted renderer | platform reviews template and engine changes together | narrowest envelope if fonts and assets are pinned | team owns patching, scaling, and render failures | low at the API boundary, potentially high in template syntax |
| Domain-owned templates, self-hosted renderer | domain teams ship within platform validation rules | wider tail; admission tests and quotas become critical | shared unless ownership is explicit | similar syntax risk plus internal tooling cost |
| Managed rendering with customer-owned templates | template authors move quickly within service limits | provider capacity helps, but input complexity still needs limits | less engine operations, more dependency and escalation work | depends on template language, export path, and artifact contract |
I use a capacity-planning reflex here: define the bounded resource before choosing the machinery. Set per-job limits for input bytes, attachment count, decoded image dimensions, render duration, and output pages according to the marketplace's actual risk tolerance. Then model worker concurrency from measured CPU and peak resident memory across representative template classes. Do not derive concurrency from average pages per document. Averages erase the tail that pages people.
The buy-versus-build review should ask whether template definitions can be exported, whether font and engine versions can be pinned, how failed jobs are diagnosed, and whether the artifact contract survives a provider change. It should also assign template incident ownership. A platform team that accepts arbitrary layout changes but owns every alert has created an unpriced service obligation.
Test the workload envelope, not a golden PDF
Pixel or structural comparison of a known fixture is useful, but one golden file does not establish an operating envelope. Build a corpus around boundary behavior: the longest allowed seller name, mixed scripts supported by the font policy, portrait and landscape attachments, transparent images, tables that nearly cross a page, and documents exactly at each admission limit. Pin the template revision and rendering environment so a changed output has an attributable cause.
For each candidate revision, capture output page count, render duration, peak memory, artifact bytes, and whether required watermark text appears on every externally shareable page. Page-count changes are not automatically failures; an unexplained change is a release signal. Run malformed and oversized inputs too, because rejection should be quick, classified, and visible rather than consuming a worker until its deadline.
Deployment follows the same logic. Canary a template revision, compare its latency and page-count distributions with the prior revision, and retain the ability to route new jobs back. Jobs already accepted must continue referencing their immutable revision. Otherwise, a retry after rollout can produce a materially different document from the same job identifier.
The final alert threshold deserves skepticism. Set it too late and the queue consumes the sharing SLO before anyone can add capacity or stop a bad revision. Set it too early and normal bursts wake an engineer, train responders to ignore the page, and add on-call cost without protecting users. Start from the user-visible deadline, subtract a measured high-percentile render budget and response time, then alert on oldest eligible job approaching the remaining queue budget for a sustained window. Revisit the threshold when template ownership, admission limits, or worker shape changes.
False positives are capacity costs too.
Further reading
- ISO 32000-2, Portable Document Format: https://www.iso.org/standard/75839.html
- OpenTelemetry, Metrics data model: https://opentelemetry.io/docs/specs/otel/metrics/data-model/
- Google SRE Workbook, Alerting on SLOs: https://sre.google/workbook/alerting-on-slos/
- OWASP, File Upload Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
Top comments (0)