The Loudest Tenant in the Room: Solving the Noisy Neighbour Problem in a Shared Imposition Service

Think about how a press shop allocates its most expensive machine. One large client arrives with a hundred urgent jobs. The foreman cannot simply hand the entire press over; other clients have orders in the queue, and the press does not discriminate by customer. Left unmanaged, that one client’s backlog would idle everyone else’s work until it’s cleared.

Shared infrastructure behaves the same way. When multiple tenants submit compute-intensive jobs to a single processing pool, the tenant with the most jobs pending tends to consume the most capacity, not because the system favours them, but because volume wins. In a multi-tenant PDF imposition service, where each job invokes a node-locked CLI tool against a fixed cluster of eight slots, that dynamic is not hypothetical. It is the default.

This article describes how the noisy neighbour problem is addressed across three distinct enforcement layers in a queue-driven imposition architecture, and what each layer can and cannot guarantee.

Why imposition makes this problem acute

PDF imposition, the process of arranging customer artwork onto a press-ready plate file, is computationally expensive and time-bounded. Each job downloads a multi-page input PDF and a layout specification, invokes the pdfToolbox CLI, and uploads the resulting plate file. A job holds a processing slot for its entire duration. Slots are the scarce resource: each licensed EC2 instance can run up to eight concurrent processes.

The capacity ceiling is fixed by licence, not by compute. Scaling requires procuring additional node-locked licences, which is a 48-hour process. There is no elastic reserve to draw on when one tenant submits a burst. The eight slots are all there is.

In this environment, a single tenant running a bulk re-print campaign or recovering from a webhook failure loop can hold four to six slots simultaneously, leaving every other tenant fighting over the remainder. At eight slots total, four tenants each holding two slots is fair. One tenant holding six is not.

The three enforcement layers

Fairness is not solved by a single mechanism here. The problem appears at three different points in the job lifecycle: at the broker, before a slot is acquired; at the worker, when a slot is requested; and upstream, before a job is submitted at all. Each point needs its own control.

Layer 1: Broker-level scheduling via MessageGroupId

Every tenant’s application sets a MessageGroupId attribute on each SQS message it publishes, using the tenant’s canonical identifier as the value. AWS introduced fair queues for SQS standard queues in July 2025. When SQS detects a noisy message group, it deprioritises that group’s messages in favour of quieter groups; it does not drop or throttle them.

SQS uses two independent conditions to identify a noisy group. The concurrency-share condition fires when a single message group holds more than 10% of all in-flight messages across the queue and has at least 30 of its own messages in flight simultaneously. A second condition, processing-time-share, fires when a group’s recent share of total consumer processing time exceeds 10%, which can detect slow-processing tenants even when their in-flight message count is low.

For this architecture, the concurrency-share condition will not activate. With eight total processing slots across the cluster, no single tenant can ever hold 30 messages in flight at the same time; the ceiling makes that impossible. Processing-time-share is plausible if one tenant’s jobs run significantly longer than others’, consuming a disproportionate share of worker time relative to their message count.

MessageGroupId should still be set on every submission, because it is the prerequisite for any fair-queue behaviour and costs nothing. Fair queues are best treated as a supplementary safety layer for processing-time anomalies, not as the primary delivery fairness control. At eight slots, the worker-side cap does the load-bearing work.

Layer 2: Runtime lease cap per tenant at the worker

The worker enforces a per-tenant concurrency cap using a DynamoDB-backed lease mechanism. Before starting any job, the worker runs an atomic transaction that checks the tenant’s current in-flight count against their cap (default: four of eight slots) and either acquires the slot or defers if the tenant is at their limit.

The transaction is atomic by design. It increments a denormalized in-flight counter, records the lease, and marks the job as in-progress in a single DynamoDB TransactWriteItems call. A race between two worker goroutines attempting to acquire the last slot for the same tenant resolves cleanly: one succeeds, the other sees the transaction cancelled and defers the message with a short visibility extension.

When a tenant is at cap, their messages are not dropped and not sent to the dead-letter queue. The worker calls ChangeMessageVisibility to push the message back by 30 seconds, leaving it in the queue for redelivery when a slot opens. From the tenant’s perspective, their jobs wait; they do not fail.

This cap is the primary fairness control at runtime. The default of four of eight means a single tenant cannot hold more than half the cluster. Two heavy tenants at their caps leave no slots for others, which is a known limitation of the single node design. The cap value is configurable per tenant via a tier override in the configuration table, which gives operators a lever when specific tenants have legitimately different throughput requirements.

A heartbeat goroutine keeps each lease alive. It extends the lease in DynamoDB every 60 seconds while the job runs. If a worker crashes, the heartbeat stops, the lease expires in roughly 10 minutes, and a background sweep reclaims it, decrementing the counter and returning the slot to the pool. A crashed worker cannot hold a slot indefinitely; the cap self-corrects without operator intervention.

Layer 3: Admission control upstream, on the tenant side

The first two layers control what happens after a job reaches the queue. Admission control addresses the problem before submission.

Three mechanisms are recommended for implementation in each tenant’s application. Token idempotency is the highest priority: before publishing a job, the tenant’s application checks whether a job with the same token is already in flight or already completed, and rejects the submission if so. A Redis set with a 48-hour TTL handles this cheaply. It catches the failure mode where a webhook retry loop or a buggy publisher re-submits the same logical job, which is the most common source of unexpected volume spikes.

Rate limiting bounds submission rate per tenant, suggested at 100 jobs per minute, well above legitimate peak demand. A tenant with a runaway retry loop will hit the rate limit and surface an error before filling the queue.

An in-flight cap at submission time complements the worker-side cap by preventing queue depth from growing unboundedly when processing is slow. The tenant’s application tracks how many jobs have been submitted but not yet confirmed via webhook, and refuses to submit more when that count reaches a ceiling, suggested at 500 jobs. Without this, a slow period at the worker can accumulate thousands of queued jobs that represent legitimate submitted work with no SLA signal back to the tenant.

These mechanisms are implemented by the tenant’s application, not by the shared imposition service. This matters for accountability: the tenant’s admission control protects the tenant’s own licence consumption and queue storage cost, not just other tenants. A tenant that skips admission control bears the cost of their own runaway code through slower job processing, not through disruption to others.

What the system cannot prevent

Two heavy tenants each at their cap of four saturate the cluster. A third tenant submitting jobs at that moment will queue. SQS will deliver their messages, but the worker defers each one on arrival: the transaction that checks available slots finds none, extends the message visibility by 30 seconds, and releases it back to the queue. Queue wait time grows for the third tenant in proportion to how long the heavy tenants’ jobs run.

This is the residual risk of a fixed-capacity shared pool. The cap prevents one tenant from taking all eight slots; it does not guarantee every tenant will always find a slot available immediately. Three or more simultaneously heavy tenants will produce queue wait times.

Slot utilisation above 80% sustained across more than half of working days, combined with queue wait times above 3 minutes on more than 20% of jobs over a four-week window, is the signal that a second licence is warranted. When both metrics are present simultaneously, the system is at capacity. Until both are present simultaneously, one of the two conditions is usually a transient burst, not a capacity constraint.

What to instrument

The fairness controls only work if operators can see when they are firing. Three metrics reveal the system’s actual behaviour under tenant load.

cap_defer per tenant counts how many times a worker deferred a message because the tenant was at their concurrency cap. A sustained high rate for one tenant means their jobs are long relative to their cap, or they are submitting faster than the cluster can process. It is not an error state; it is the cap working correctly. But a sudden spike in cap_defer for a tenant that previously ran smoothly signals a change in their job characteristics or submission rate.

lease_recovered counts how many times the background sweep reclaimed an expired lease. Occasional recovery is normal and reflects worker restarts or brief processing anomalies. Sustained nonzero recovery for a specific tenant points to a worker-side problem affecting that tenant’s jobs specifically, or a heartbeat issue on one of the nodes.

Queue wait time, specifically the age of the oldest unacknowledged message in SQS, is the end-to-end signal. The cap is the primary control that affects this number directly. If one tenant’s cap_defer rate is high and cluster-wide queue wait time is also rising, the two conditions are related: that tenant’s deferred messages are ageing in the queue. Rising queue wait time with no single tenant’s cap_defer elevated points to a cluster-wide throughput constraint, not a per-tenant fairness issue.

The press shop analogy, completed

The foreman’s response to the large client with a hundred urgent jobs is not to hand over the press. It is to give that client a fair share: four slots of the eight available, no more, regardless of how many jobs they have waiting. The queue dispatcher ensures their backlog does not crowd out the delivery of other clients’ work orders. And if the client’s own scheduling team is generating duplicate work orders, that is caught at the intake desk before the jobs reach the shop floor at all.

The imposition service applies that same logic mechanically, at three points in the job flow. At the broker, MessageGroupId enables SQS fair queues to deprioritise a tenant whose jobs are running slow, though with an eight-slot ceiling this protection is limited to processing-time anomalies. Their in-flight jobs cannot exceed the cap at the worker. A buggy publisher on their side cannot fill the queue with phantom work. Each layer addresses the problem at the point where it actually arises.

Fairness is not a feature. It is what the architecture defaults to when the controls are in place.

Leave a Reply

Your email address will not be published. Required fields are marked *