IFR Avionics Trainer

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:

  1. Fetch a scenario's definition (read-heavy, rarely changes)
  2. Write a telemetry snapshot during an active session (write-heavy, bursty, every few seconds)
  3. Fetch every snapshot for one session (needed once, at session-end, for scoring)
  4. Fetch a user's session history, most recent first (progress dashboard)
  5. 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

EntityPartition keySort keyServes
ScenarioSCENARIO#<scenarioId>METAPattern 1 - direct GetItem
SnapshotSESSION#<sessionId>SNAPSHOT#<isoTimestamp>Patterns 2, 3 - Query sorted by time
Anomaly flagSESSION#<sessionId>ANOMALY#<isoTimestamp>Pattern 3 - same partition as snapshots
Score summaryUSER#<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.