Aerospace / data platform

Decoupling two workloads on an event platform

A platform where machine ingestion and human state updates shared one code path, and separating them as the only durable fix.

Role
Tech lead, backend & cloud
Period
2025
  • AWS Lambda
  • DynamoDB
  • SNS
  • SQS FIFO
  • API Gateway
  • Java
  • Spring Boot

Context

An industrial data platform in aerospace, ingesting millions of events per day. Each event carries a progress state that a human operator moves through a fixed lifecycle:

to review → open → scheduled → under watch → closed

So two very different workloads travelled the same path: on one side a high-volume machine ingestion, continuous and indifferent to the odd second; on the other a low-volume human interaction, where every millisecond shows because an operator is sitting in front of a screen waiting.

Constraint

Both workloads shared the same code path and the same data model. Three consequences compounded.

First, state transitions were processed inline with ingestion. Every ingestion carried transition work it did not need, and every state update queued behind the ingestion backlog.

Second, calls to the operator-facing API competed with ingestion traffic for the same resources. A spike in ingestion, that is, a perfectly ordinary moment, directly degraded the user experience.

Third, the DynamoDB key design matched neither access pattern. The reads operators needed were not served by the keys, and therefore fell back to scans.

The diagnosis that mattered is none of those three symptoms taken alone. It was not “DynamoDB is slow”, nor “we need more Lambdas”: it was that two workloads with opposite characteristics had been modelled as one. For as long as that conflation held, every local optimisation merely moved the problem, because speeding up a shared path also speeds up what should never have been on it.

Decisions

MACHINE WORKLOADHUMAN WORKLOADisolated pathsIngestionmillions of events / daySNSSQS FIFOMessageGroupId = one entityTransition LambdaOperatorlow volume, visible latencyAPI GatewayAPI LambdaDynamoDBPK: event identifier · SK: timestampone item per state change · GSIs for operator queries
The two workloads once separated. Ingestion publishes a notification and returns; transition processing happens apart, serialised per entity by the FIFO queue. The operator API has its own function, and the two paths meet only at the table.

Decouple ingestion from transition processing. When ingestion finishes processing an event, it publishes a notification to SNS instead of doing the transition work itself. SNS fans out to SQS, and SQS triggers a dedicated transition Lambda. Ingestion returns as soon as its own job is done, carrying nothing that isn’t its concern. The trade-off is explicit: progress state becomes eventually consistent with ingestion rather than immediately consistent. We accepted it because the consumer of that state is a human whose working loop is measured in minutes, not another machine about to make an automated decision within the second. The same decoupling would have been the wrong call had a downstream system depended on that immediate consistency.

Give concurrency to the queue rather than to code. Several events concerning the same entity could otherwise be processed simultaneously and produce an inconsistent final state. The reflex answer is a distributed lock: a lock table, leases, expiry timeouts, and the whole class of bugs that comes with them. We preferred to use the queue’s own guarantees. A MessageGroupId per entity means messages in the same group are processed strictly in order, one at a time, while different groups continue to be processed in parallel: ordering is guaranteed exactly where it matters and nowhere else, so without sacrificing overall throughput. A MessageDeduplicationId protects against duplicate publishes. This is the decision I am happiest with in the whole overhaul, not because it is clever but because it is boring: the guarantee is provided by the infrastructure, it is tested by AWS, and there is no home-grown coordination layer to maintain or to debug at three in the morning.

Reshape the DynamoDB table around the real access patterns. The partition key became the event’s unique identifier, the sort key its arrival timestamp. Two consequences are worth spelling out. First, each state change is written as a distinct item instead of mutating a single row: there is no read-modify-write cycle any more, and therefore no lost update when two processes cross. Second, state history comes for free: operators needed the complete history of an event’s states, and a query on the partition key returns it already ordered by date. No separate audit table, no second write path to keep consistent with the first. A feature we would otherwise have had to build became a property of the data model.

Isolate the human-facing API in its own Lambda. API Gateway routes to a dedicated function, separate from the ingestion path. Ingestion spikes no longer degrade the operator experience, and more importantly the two can be sized and reasoned about independently: the reserved concurrency, timeouts and memory profile of one no longer have to be a compromise with those of the other.

Replace scans with secondary indexes. Some operator queries did not align with the primary key. Rather than scanning the table and optimising at the margins, we added global secondary indexes shaped for those specific queries. The underlying principle is worth stating, because it governs everything else: in DynamoDB you model the table around the queries you know you will run. A scan on a hot path is not a query to optimise, it is the signal that the data model is wrong.

Outcome

The two workloads no longer compete for the same resources: an ingestion spike, that is, an ordinary day, no longer degrades the operator experience, and the two paths can now be sized and reasoned about separately. Per-entity processing order is guaranteed without a single line of coordination code, and therefore without the class of bugs that comes with distributed locks. As for state history, which would otherwise have required dedicated development with its own write path, it became a plain property of the data model.

What this overhaul confirmed goes beyond the particular case: when two workloads with opposing constraints share a path, no optimisation of that path can satisfy both. You have to separate them first, after which the decisions become simple, because each half now has only one goal to serve.