Skip to content

Repository files navigation

For interviewers and evaluators, Quick Project Walkthrough. View Deck

Wayline

A connected-vehicle data pipeline built to the shape of the problem: many car manufacturers send the same physical facts (speed, fuel, location) in different field names and different units, and all of it has to be pulled in, converted into one consistent model, watched for meaningful events in real time, and served to a UI.

Everything runs locally in Docker. The stack is TypeScript end to end.

What it does

Functional requirements

  • Ingest raw OEM feeds from Ford, Toyota, and BMW. Each sends speed, fuel, temperature, and location in its own shape.
  • Normalize every feed into one canonical model so nothing downstream knows which brand a car came from.
  • Load telemetry into Postgres with batched, idempotent writes. Replaying the same message changes nothing.
  • Detect events on the canonical stream: hard braking, extended idling, geofence exits, and low fuel.
  • Send notifications through Hasura event triggers, also idempotent, so retries never spam.
  • Serve a live dashboard over GraphQL subscriptions: a map, an event feed, and a fleet table.

Non-functional requirements

  • Durability. Kafka is a distributed append-only log, not a queue. Messages are kept until retention expires and each consumer group tracks its own offset, so a buggy consumer can be fixed and rerun over history.
  • Ordering. One vehicle's readings must stay in time order, otherwise a detector cannot tell a hard brake from two unrelated readings. Kafka guarantees order only inside a partition, so every message is keyed by vehicleId.
  • Fault tolerance. The local cluster runs three Kafka brokers in KRaft mode with replication factor 3 and min.insync.replicas = 2. Losing one broker does not stop reads or writes.
  • Idempotency by natural key. The loader, detector, and notifier all ignore duplicate deliveries rather than trying to prevent them.
  • Separation of concerns. Normalization, loading, detection, and notification are separate services that communicate through Kafka or Postgres, not direct calls.

The dashboard

A small React app shows the fleet live over GraphQL subscriptions.

The fleet map and live event feed

The dots are vehicles, coloured by how fresh their last reading is. The event feed on the right fills in real time as the detector raises hard brakes, extended idles, and geofence exits.

The fleet table makes the point of the two timestamps visible. The rows from the slow feed read "delayed" while the others read "live", because that manufacturer publishes about eight seconds behind.

The full dashboard, including the fleet table coloured by staleness

System design

flowchart LR
  subgraph sim["OEM simulators"]
    F[Ford<br/>mph, camelCase]
    T[Toyota<br/>kmh, snake_case]
    B[BMW<br/>kmh, German keys]
  end

  subgraph kafka["Kafka — distributed log"]
    OF[oem.ford]
    OT[oem.toyota]
    OB[oem.bmw]
    CT[canonical.telemetry]
    DLQ[telemetry.dlq]
  end

  subgraph pipeline["Stream processing"]
    N[Normalizer]
    L[Loader]
    D[Detector]
  end

  subgraph store["Storage & serving"]
    PG[(Postgres)]
    H[Hasura / GraphQL]
    NT[Notifier]
  end

  F -- key=vehicleId --> OF
  T -- key=vehicleId --> OT
  B -- key=vehicleId --> OB
  OF --> N
  OT --> N
  OB --> N
  N --> CT
  N -.rejected.-> DLQ
  CT --> L
  CT --> D
  L --> PG
  D --> PG
  PG --> H
  PG -.event trigger.-> NT
  H --> UI[React dashboard]
Loading

Data flows left to right. Everything after the normalizer works with one clean format and never sees brand-specific code again.

Same fact, three languages

flowchart TD
  F["Ford: speedMph 62"] --> N[Normalizer]
  T["Toyota: velocity_kmh 100"] --> N
  B["BMW: geschwindigkeit_kmh 100"] --> N
  N --> C["one shape: speed_kph 100"]
Loading

Ford uses miles, the others use kilometres, and BMW writes in German. The normalizer has one small adapter per brand. Adding a brand later means writing one adapter and nothing else.

Kafka cluster and Kafka UI

The local stack runs three brokers in KRaft mode. Each broker is also a controller, and the three controllers form a Raft quorum that elects partition leaders without ZooKeeper.

Every topic is created with replication factor 3 and min.insync.replicas = 2, so a write is acknowledged once the leader and one follower have it. The cluster survives the loss of any single broker.

Kafka UI is at http://localhost:8080 once the brokers are healthy. It shows the cluster overview, topics, partitions, replicas, consumer groups, and live messages.

Kafka UI cluster overview

Kafka UI topic list

Kafka UI topic overview with replication

Kafka UI messages on canonical.telemetry

Kafka UI brokers

Kafka UI consumers

Is this data fresh or old?

Every reading carries two timestamps: when the car measured it, and when we received it.

flowchart LR
  A["car measured it<br/>10:00:00"] -->|8 second gap| B["we received it<br/>10:00:08"]
Loading

That gap is staleness. One feed runs about eight seconds behind, so on the dashboard its cars show as "delayed" while the others show as "live". Without the second timestamp you would serve old data as if it were current and never notice.

Spotting a hard brake

You cannot tell from one reading; "speed is 40" means nothing on its own. So the detector keeps the last few readings and looks for a pattern.

flowchart LR
  R1["80"] --> R2["78"] --> R3["40"]
  R3 --> Q{"dropped 38<br/>in 2 seconds?"}
  Q -->|yes| E["hard brake"]
Loading

This "remember the last few" is a sliding window. The same idea finds idling, leaving an area, and low fuel.

Keeping one car's readings in order

Kafka splits a topic into lanes and only promises order within a lane. Tagging each message with the car's id makes Kafka always put the same car in the same lane.

flowchart LR
  L0["lane 0: FORD-002 readings, in order"]
  L1["lane 1: FORD-003 readings, in order"]
  L2["lane 2: FORD-001 readings, in order"]
Loading

So one car's readings stay in order even with several workers reading at once. That is the whole reason "80 then 40" can be trusted to mean a brake, and it is measured in the scale experiment: 1602 readings, none out of order.

Duplicates are made harmless

Kafka and Hasura can deliver the same message twice after a hiccup. Rather than prevent that, the system makes a repeat do nothing: each item has a natural key, and saving a key that already exists is ignored.

flowchart TD
  A[message arrives] --> B{seen this<br/>exact one before?}
  B -->|no| C[save it and act]
  B -->|yes| D[ignore it]
Loading

This is why replaying the whole stream changes nothing, and it appears in the same shape four times: the loader, the detector, the notifier, and the end-to-end suite.

Why alerts use a different path from detection

flowchart TD
  E[(an event happens)]
  E -->|order matters| D["detector reads Kafka in order"]
  E -->|order does not matter| A["alerts fired by a Hasura trigger"]
Loading

The detector must see readings in order, so it reads Kafka. The alert sender does not care which alert goes out first, so a Hasura trigger's parallel delivery is fine for it. Using the right tool for each is the point.

Why the pieces are what they are

  • Apache Kafka sits between every producer and every consumer. It is a durable, replayable log, not a queue: messages are not deleted when read, so a consumer with a bug can be fixed and re-run over the same history. Keying messages by vehicle guarantees that one vehicle's readings stay in order, which is what makes time-based event detection possible.
  • Three brokers in KRaft mode make the durability claim real. With replication factor 3 and min.insync.replicas = 2, the cluster keeps working if any single broker is lost.
  • A normalizer converts each manufacturer's raw format into one canonical model. This is the core problem and it is deliberately kept in one place.
  • Postgres plus Hasura serve the normalized data. Hasura generates the GraphQL API and its live subscriptions directly from the database schema, so there is no hand-written API layer.

Current layout

Requirements: Docker with Compose, Node 20 or newer, pnpm, and the Nhost CLI.

pnpm install          # install workspace dependencies
pnpm kafka:up         # start the 3-node Kafka cluster and Kafka UI
pnpm topics:setup     # create the declared topics
pnpm smoke            # produce and consume test messages end to end
pnpm test             # run unit tests
pnpm nhost:up         # start Postgres and Hasura
pnpm loader           # write the canonical stream into the database
pnpm detector         # raise events from the stream
pnpm notifier         # send alerts via Hasura event trigger
pnpm dashboard        # http://localhost:3000

Kafka UI is at http://localhost:8080. The cluster has three brokers; connect to any one to reach all of them. From the host the brokers are at localhost:9092, localhost:9094, and localhost:9096, and from other containers at kafka-1:29092, kafka-2:29092, and kafka-3:29092. A client only needs one of these as its bootstrap: the broker it names hands back the full list.

To stop the broker:

pnpm kafka:down

The default setup uses one partition per topic. That keeps ordering trivial to reason about while the pipeline is being built, but it also caps parallelism: each consumer group can have only one active reader per topic.

Numbers at the current scale

The unit tests cover the logic that can be checked without infrastructure: unit conversions, the window arithmetic, the ordering rule, staleness buckets, payload parsing. They run in about a second.

The real ceiling of the current layout is not a hard number from Kafka — it depends on the single broker's disk, the loader's Postgres round-trips, and the detector's memory — but the architecture makes the next bottlenecks predictable:

The end-to-end suite covers the properties that only appear once the pieces are wired together and need Kafka, Postgres and Hasura running. It brings the whole stack up, drives five real scenarios against the services, prints a table, and tears the stack down.

What adding partitions looks like

flowchart LR
  subgraph before ["Before: 1 partition"]
    T1[canonical.telemetry]
    D1[Detector]
    T1 --> D1
  end

  subgraph after ["After: 3 partitions"]
    P0[partition 0]
    P1[partition 1]
    P2[partition 2]
    D0[Detector 0]
    D1b[Detector 1]
    P0 --> D0
    P1 --> D0
    P2 --> D1b
  end

  before -->|grow partitions| after
Loading

Set E2E_KEEP_INFRA=1 to run against a stack that is already up and leave it running, which is faster while iterating.

Set E2E_KEEP_INFRA=1 to run against a stack that is already up and leave it running, which is faster while iterating.

Layout

docker-compose.yml          3-node Kafka (KRaft, replication factor 3) and Kafka UI
packages/kafka              shared Kafka client, config, topic registry, and admin
packages/canonical        the one reading shape everything downstream agrees on
services/simulator        the manufacturer simulators, one profile per OEM
services/normalizer       per-manufacturer adapters onto the canonical shape
services/loader           batched, idempotent writes into Postgres
services/detector         sliding-window detection of events on the stream
services/notifier         idempotent notifier behind a Hasura event trigger
apps/dashboard            live React dashboard, coloured by staleness
nhost/migrations          the schema, as versioned migrations
nhost/metadata            which tables Hasura exposes, and to whom
scripts/                  topic setup, repartition, ordering check, smoke check
scripts/e2e               the end-to-end suite: bring up, drive, tear down
docs/architecture.md      full design, decisions, and roadmap
docs/scale-experiment.md  partitioning, parallelism, and the ordering proof
docs/HLD.md               high-level design for a quick overview

One simulator service runs three times with different configuration rather than existing as three near-identical copies. The physics is shared; only the output format differs, so adding a manufacturer means adding one profile under services/simulator/src/profiles and registering it. Nothing else changes.

About

A connected-vehicle data pipeline: many OEM feeds normalized into one canonical model, real-time event detection over an ordered Kafka stream, and a live dashboard.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages