Designing an Async Validation Pipeline for an IFR Avionics Trainer
Status: this is a design document for a personal project that is still in the planning stage. No code has been written yet - this post captures the architecture and data modeling decisions made so far.
The idea
A browser-based IFR (Instrument Flight Rules) procedure trainer that emulates aircraft avionics - the primary flight display, multi-function display, autopilot, and FMS - without rendering a full 3D world. Real flight simulators model physics and terrain; this project deliberately skips that, treating the avionics as a state machine driven by synthetic flight data. That keeps the whole thing lightweight enough to run in a browser tab, while the backend handles scenario delivery, session telemetry, and performance scoring.
This post focuses on the backend: how session data flows through AWS, and why it's shaped the way it is.
Step 1: Start from access patterns, not entities
DynamoDB rewards designing around how you'll read data, not normalizing entities the way you would in a relational schema. Before choosing any keys, the access patterns this system needs are:
- Fetch a scenario's definition (read-heavy, rarely changes)
- Write a telemetry snapshot during an active session (write-heavy, bursty, every few seconds)
- Fetch every snapshot for one session (needed once, at session-end, for scoring)
- Fetch a user's session history, most recent first (progress dashboard)
- Fetch the score summary for one session (read-heavy afterward, cheap)
Every key decision below exists to serve one of these five patterns.
Step 2: Single-table schema
| Entity | Partition key | Sort key | Serves |
|---|---|---|---|
| Scenario | SCENARIO#<scenarioId> | META | Pattern 1 - direct GetItem |
| Snapshot | SESSION#<sessionId> | SNAPSHOT#<isoTimestamp> | Patterns 2, 3 - Query sorted by time |
| Anomaly flag | SESSION#<sessionId> | ANOMALY#<isoTimestamp> | Pattern 3 - same partition as snapshots |
| Score summary | USER#<userId> | SESSION#<isoTimestamp> | Patterns 4, 5 |
ISO 8601 timestamps in the sort key matter because DynamoDB sorts lexicographically by string - so "most recent sessions first" is a plain Query with ScanIndexForward: false, no application-side sorting required.
The key modeling decision: the same logical "session" exists as two separate physical items in two separate partitions - raw telemetry under SESSION#, and the computed summary under USER#. That split exists because each is shaped for a different read. The summary doesn't need to share a partition with the hundreds of snapshot rows it was computed from; it only needs to be fast to find when rendering a dashboard.
Step 3: Why validation runs asynchronously
The client computes flight state locally every tick and could, in principle, be tampered with to fake an impossible approach. Two options exist for catching that:
- Synchronous: the client waits for the backend to confirm each snapshot before continuing
- Asynchronous: the client writes snapshots and moves on; the backend validates in the background and flags anomalies after the fact
This project takes the async path. The tradeoff is explicit: a determined user could finish a fraudulent session before validation catches up. For a training tool - not a competitive or monetized product - that's the correct tradeoff. Optimizing for a smooth, non-blocking flying experience matters more than airtight real-time enforcement.
Step 4: The Lambda pipeline
Three Lambdas, each with a distinct trigger:
Lambda A - ingest (API Gateway triggered)
in: POST /sessions/{id}/snapshot
do: PutItem → SESSION#<id> / SNAPSHOT#<timestamp>
out: 202 Accepted immediately - client never waits
Lambda B - validator (DynamoDB Streams triggered, filtered to SNAPSHOT# writes)
in: stream event for the new snapshot item
do: check elapsed time vs. altitude/heading/speed delta against
the aircraft's performance envelope
out: PutItem → SESSION#<id> / ANOMALY#<timestamp> - only on violation
Lambda C - scorer (invoked on session end)
in: POST /sessions/{id}/complete
do: Query SESSION#<id> for all SNAPSHOT# and ANOMALY# items,
compare against the scenario's ideal path, compute deviation metrics
out: PutItem → USER#<userId> / SESSION#<timestamp> (the summary)
DynamoDB Streams is what makes this decoupled rather than orchestrated: Lambda B doesn't need to be told "go check session X" - it fires automatically on every snapshot write. No polling, no queue management code, no orchestration layer.
One detail worth naming explicitly: Lambda B fires per snapshot record by default, which for a 20-minute session at one snapshot every 5 seconds is roughly 240 invocations. DynamoDB Streams supports batching multiple records per invocation via the event source mapping's BatchSize setting, which would reduce that - but for the realistic concurrency this project expects, per-record invocation is simpler, and the cost difference is negligible at this scale. Batching is a tuning knob to revisit if usage ever grows past hobby scale.
What's next
This document captures the schema and pipeline design. The next step is implementing Lambda A and the ingest path first, since every other piece depends on snapshots actually existing to validate and score.