CodingNic

Backend Services

Prompting the Poller and Detection Engine

Backend Services 45 min read

Prompting the Poller and Detection Engine

Objectives

By the end of this chapter, you should be able to:

  • Direct an AI assistant to build a background poller that fetches live aircraft data from adsb.lol on a fixed interval, without letting overlapping cycles race each other
  • Get an AI assistant to build edge-triggered detection, and recognize the difference between “alert once per transition” and “alert every cycle it’s still true”
  • Verify emergency squawk and overflight detection actually work against a real database, not just against a confident summary of what the code does

💡 Why this matters: By the end of this lesson the poller runs standalone, writing to Postgres on its own, with nobody watching yet. That also means its two easiest-to-miss bugs, a poller that overlaps itself and a detector that spams duplicate alerts, will run for as long as the process stays up before anyone notices. This is the lesson to catch them in, not three weeks from now in a log file.

Two Failure Modes That Never Show Up in a Quick Test

A poller that fetches on a fixed interval has one structural risk that a quick manual test will never surface: setInterval doesn’t wait for the previous callback to finish. If a single cycle ever takes longer than the poll interval, whether adsb.lol is slow that second or the database write is under load, the next tick fires anyway, and now two cycles are running concurrently against the same in-memory detection state. In development, against a fast local Postgres and a responsive upstream, every cycle finishes in well under fifteen seconds, so this never happens in front of you. It happens the first time something upstream is slow, which is exactly the kind of thing you can’t reliably reproduce on demand, which is exactly why it needs to be prevented structurally rather than caught by watching logs.

Detection has the same shape of problem for a different reason. An aircraft squawking 7700 stays on that code for the whole incident, maybe ten poll cycles in a row. Detection logic that asks “is this aircraft currently squawking an emergency code” and writes a row every time it’s true will pass every test where you only check “does an alert get created,” because one does. It just also creates nine more you didn’t want. The only way this class of bug is visible is by asking specifically: does this fire once, or does it fire every cycle the condition holds? Both prompts below name these two failure modes directly, because neither is something an AI assistant will surface unprompted; a poller and a detector that behave this way still look, from the outside, like they’re doing exactly what was asked.

The Prompt

code
Building the SKYWATCH poller in apps/server/src/poller, on top of the Module 1 schema and the watch-regions resource from the previous lesson. This needs the shared aircraft/geo types, an adsb.lol client, edge-triggered detection, storage, and the loop that ties it together. Shared types (packages/shared): - aircraft.ts: AircraftState matching adsb.lol's real field names (hex, flight, r, t, alt_baro as number | "ground" | null, alt_geom, gs, baro_rate, geom_rate, track, squawk, category, lat, lon), plus EMERGENCY_SQUAWKS (7500/7600/7700), isEmergencySquawk, altitudeFt (normalizes the "ground" string case), isOnGround. - geo.ts: distanceNm(lat1, lon1, lat2, lon2) using the haversine formula, nautical miles. - location.ts: HomeLocationSettings (lat, lon, radiusNm, altitudeCeilingFt, updatedAt). adsb.lol client (apps/server/src/poller/adsbClient.ts + lib/http.ts): fetchPoint(lat, lon, radiusNm) hitting https://api.adsb.lol/v2/point/{lat}/{lon}/{radius}, radius clamped to 1-250. adsb.lol rejects requests with no browser-like User-Agent, so set one. Wrap fetch failures and non-2xx responses in a distinct UpstreamError class so the poller can tell "upstream had a bad response" apart from "our own code threw." Detection (apps/server/src/poller/detect.ts) -- this is the part I need to be precise about. Detection must be edge-triggered: keep small in-memory state (a Map or Set) tracking which aircraft are CURRENTLY in an alerting state, and only emit a detection the moment a hex transitions from not-alerting to alerting. An aircraft that stays on an emergency squawk for ten straight poll cycles must produce exactly one detection, not ten. Same requirement for overflight: one detection when an aircraft enters the geofence, nothing more while it stays inside, nothing when it's outside. Also clean up any hex that drops off adsb.lol entirely (out of range, transponder off) so it doesn't leak in the in-memory state forever. - detectSquawkTransitions(aircraft): emits on emergency squawk false->true transitions - detectOverflightTransitions(aircraft, home): emits when an airborne aircraft enters home.radiusNm and is at or below home.altitudeCeilingFt, on the false->true transition only Storage (apps/server/src/poller/store.ts): storeSnapshots (batch insert position rows in chunks of 500 to stay under Postgres's parameter limits), storeSquawkAlerts, storeOverflights. The loop (apps/server/src/poller/loop.ts): polls every env.POLL_INTERVAL_MS. Two hard requirements: 1. Re-entrancy guard -- if a poll cycle is still running when the next tick fires, skip that tick and log it, don't let cycles overlap. 2. Per-region isolation -- this app watches multiple regions concurrently. One region's upstream fetch failing must not abort any other region's fetch/detect/store in the same cycle. Also seed a default watch region on first run if none exist yet, and wire start/stop into the server's existing shutdown handler. Show me every file.

What It Built

ts
// packages/shared/src/aircraft.ts
export interface AircraftState {
  hex: string;
  flight?: string | null;
  r?: string | null; // registration
  t?: string | null; // ICAO type designator, e.g. "B738"
  alt_baro?: number | "ground" | null;
  alt_geom?: number | null;
  gs?: number | null; // ground speed, knots
  baro_rate?: number | null; // vertical rate, fpm
  geom_rate?: number | null;
  track?: number | null;
  true_heading?: number | null;
  mag_heading?: number | null;
  squawk?: string | null;
  category?: string | null;
  lat?: number | null;
  lon?: number | null;
}

export interface AdsbPointResponse {
  ac?: AircraftState[];
  total?: number;
  now?: number;
}

export const EMERGENCY_SQUAWKS = new Set(["7500", "7600", "7700"]);

export function isEmergencySquawk(squawk?: string | null): boolean {
  return !!squawk && EMERGENCY_SQUAWKS.has(squawk);
}

/** Normalizes alt_baro (which can be the literal string "ground") to a number or null, in feet. */
export function altitudeFt(a: Pick<AircraftState, "alt_baro" | "alt_geom">): number | null {
  if (a.alt_baro === "ground") return null;
  if (typeof a.alt_baro === "number") return a.alt_baro;
  return a.alt_geom ?? null;
}

export function isOnGround(a: Pick<AircraftState, "alt_baro">): boolean {
  return a.alt_baro === "ground";
}
ts
// packages/shared/src/geo.ts
export function distanceNm(lat1: number, lon1: number, lat2: number, lon2: number): number {
  const R_NM = 3440.065;
  const toRad = (d: number) => (d * Math.PI) / 180;
  const dLat = toRad(lat2 - lat1);
  const dLon = toRad(lon2 - lon1);
  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  return R_NM * c;
}
ts
// packages/shared/src/location.ts
export interface HomeLocationSettings {
  lat: number;
  lon: number;
  radiusNm: number;
  altitudeCeilingFt: number;
  updatedAt: string;
}
ts
// apps/server/src/lib/http.ts
export const UPSTREAM_USER_AGENT =
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";

export class UpstreamError extends Error {
  constructor(message: string, public readonly status?: number) {
    super(message);
    this.name = "UpstreamError";
  }
}

export async function fetchJson<T>(url: string, timeoutMs = 10_000): Promise<T> {
  let resp: Response;
  try {
    resp = await fetch(url, {
      headers: { "User-Agent": UPSTREAM_USER_AGENT, Accept: "application/json" },
      signal: AbortSignal.timeout(timeoutMs),
    });
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    throw new UpstreamError(`fetch failed: ${message}`);
  }
  if (!resp.ok) throw new UpstreamError(`upstream returned HTTP ${resp.status}`, resp.status);
  return (await resp.json()) as T;
}
ts
// apps/server/src/poller/adsbClient.ts
import type { AdsbPointResponse } from "@skywatch/shared";
import { fetchJson } from "../lib/http.js";

const ADSB_POINT_BASE = "https://api.adsb.lol/v2/point";

export async function fetchPoint(lat: number, lon: number, radiusNm: number): Promise<AdsbPointResponse> {
  const clampedRadius = Math.max(1, Math.min(250, Math.round(radiusNm)));
  const url = `${ADSB_POINT_BASE}/${lat}/${lon}/${clampedRadius}`;
  return fetchJson<AdsbPointResponse>(url);
}
ts
// apps/server/src/poller/detect.ts
import {
  altitudeFt, distanceNm, isEmergencySquawk, isOnGround,
  type AircraftState, type HomeLocationSettings,
} from "@skywatch/shared";

export interface SquawkDetection {
  hex: string;
  flight: string | null;
  squawk: string;
  lat: number | null;
  lon: number | null;
  altBaroFt: number | null;
}

const activeSquawkAlerts = new Map<string, string>(); // hex -> currently-alerting squawk code

export function detectSquawkTransitions(aircraft: AircraftState[]): SquawkDetection[] {
  const detections: SquawkDetection[] = [];
  const seenHexes = new Set<string>();

  for (const a of aircraft) {
    seenHexes.add(a.hex);
    const squawk = a.squawk ?? null;
    const wasAlerting = activeSquawkAlerts.get(a.hex);

    if (isEmergencySquawk(squawk)) {
      if (wasAlerting !== squawk) {
        detections.push({
          hex: a.hex,
          flight: a.flight?.trim() || null,
          squawk: squawk as string,
          lat: a.lat ?? null,
          lon: a.lon ?? null,
          altBaroFt: altitudeFt(a),
        });
      }
      activeSquawkAlerts.set(a.hex, squawk as string);
    } else if (wasAlerting) {
      activeSquawkAlerts.delete(a.hex);
    }
  }

  for (const hex of activeSquawkAlerts.keys()) {
    if (!seenHexes.has(hex)) activeSquawkAlerts.delete(hex);
  }

  return detections;
}

export interface OverflightDetection {
  hex: string;
  flight: string | null;
  lat: number;
  lon: number;
  altBaroFt: number | null;
  distanceNm: number;
}

const currentlyOverflying = new Set<string>();

export function detectOverflightTransitions(
  aircraft: AircraftState[],
  home: HomeLocationSettings
): OverflightDetection[] {
  const detections: OverflightDetection[] = [];
  const seenHexes = new Set<string>();

  for (const a of aircraft) {
    if (a.lat == null || a.lon == null) continue;
    seenHexes.add(a.hex);

    const alt = altitudeFt(a);
    const dist = distanceNm(home.lat, home.lon, a.lat, a.lon);
    const isOverflying =
      !isOnGround(a) && dist <= home.radiusNm && alt != null && alt <= home.altitudeCeilingFt;
    const wasOverflying = currentlyOverflying.has(a.hex);

    if (isOverflying && !wasOverflying) {
      detections.push({ hex: a.hex, flight: a.flight?.trim() || null, lat: a.lat, lon: a.lon, altBaroFt: alt, distanceNm: dist });
      currentlyOverflying.add(a.hex);
    } else if (!isOverflying && wasOverflying) {
      currentlyOverflying.delete(a.hex);
    }
  }

  for (const hex of currentlyOverflying) {
    if (!seenHexes.has(hex)) currentlyOverflying.delete(hex);
  }

  return detections;
}
ts
// apps/server/src/db/repos/homeLocationRepo.ts
import { eq } from "drizzle-orm";
import type { HomeLocationSettings } from "@skywatch/shared";
import { db } from "../client.js";
import { homeLocation } from "../schema.js";

export async function getHomeLocation(): Promise<HomeLocationSettings | null> {
  const rows = await db.select().from(homeLocation).where(eq(homeLocation.id, 1));
  const row = rows[0];
  if (!row) return null;
  return { lat: row.lat, lon: row.lon, radiusNm: row.radiusNm, altitudeCeilingFt: row.altitudeCeilingFt, updatedAt: row.updatedAt.toISOString() };
}
ts
// apps/server/src/poller/store.ts
import type { AircraftState } from "@skywatch/shared";
import { altitudeFt, isOnGround } from "@skywatch/shared";
import { db } from "../db/client.js";
import { overflightLog, positionSnapshots, squawkAlerts } from "../db/schema.js";
import type { OverflightDetection, SquawkDetection } from "./detect.js";

const BATCH_SIZE = 500;

export async function storeSnapshots(aircraft: AircraftState[]): Promise<number> {
  const withPosition = aircraft.filter(
    (a): a is AircraftState & { lat: number; lon: number } => a.lat != null && a.lon != null
  );
  if (withPosition.length === 0) return 0;

  const rows = withPosition.map((a) => ({
    hex: a.hex,
    flight: a.flight?.trim() || null,
    registration: a.r ?? null,
    typeCode: a.t ?? null,
    lat: a.lat,
    lon: a.lon,
    altBaroFt: altitudeFt(a),
    onGround: isOnGround(a),
    groundSpeedKt: a.gs ?? null,
    trackDeg: a.track ?? a.true_heading ?? a.mag_heading ?? null,
    verticalRateFpm: a.baro_rate ?? a.geom_rate ?? null,
    squawk: a.squawk ?? null,
    category: a.category ?? null,
  }));

  for (let i = 0; i < rows.length; i += BATCH_SIZE) {
    await db.insert(positionSnapshots).values(rows.slice(i, i + BATCH_SIZE));
  }
  return rows.length;
}

export async function storeSquawkAlerts(detections: SquawkDetection[]) {
  for (const d of detections) {
    await db.insert(squawkAlerts).values({ hex: d.hex, flight: d.flight, squawk: d.squawk, lat: d.lat, lon: d.lon, altBaroFt: d.altBaroFt });
  }
}

export async function storeOverflights(detections: OverflightDetection[]) {
  for (const d of detections) {
    await db.insert(overflightLog).values({ hex: d.hex, flight: d.flight, lat: d.lat, lon: d.lon, altBaroFt: d.altBaroFt, distanceNm: d.distanceNm });
  }
}
ts
// apps/server/src/poller/watchRegions.ts
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { watchRegions } from "../db/schema.js";

const DEFAULT_REGION = { name: "New York (JFK area) - default", lat: 40.6413, lon: -73.7781, radiusNm: 200 };

export async function ensureDefaultWatchRegion(): Promise<void> {
  const existing = await db.select().from(watchRegions);
  if (existing.length === 0) {
    await db.insert(watchRegions).values(DEFAULT_REGION);
    console.log(`[poller] no watch regions configured, created default: "${DEFAULT_REGION.name}"`);
  }
}

export async function getEnabledWatchRegions() {
  return db.select().from(watchRegions).where(eq(watchRegions.enabled, true));
}
ts
// apps/server/src/poller/loop.ts
import { env } from "../env.js";
import { UpstreamError } from "../lib/http.js";
import { getHomeLocation } from "../db/repos/homeLocationRepo.js";
import { fetchPoint } from "./adsbClient.js";
import { detectOverflightTransitions, detectSquawkTransitions } from "./detect.js";
import { storeOverflights, storeSnapshots, storeSquawkAlerts } from "./store.js";
import { ensureDefaultWatchRegion, getEnabledWatchRegions } from "./watchRegions.js";

let pollTimer: ReturnType<typeof setInterval> | null = null;
let pollInFlight = false;

async function pollOnce(): Promise<void> {
  if (pollInFlight) {
    console.warn("[poller] previous cycle still running, skipping this tick");
    return;
  }
  pollInFlight = true;

  try {
    const [regions, home] = await Promise.all([getEnabledWatchRegions(), getHomeLocation()]);
    if (regions.length === 0) {
      console.warn("[poller] no enabled watch regions, nothing to poll");
      return;
    }

    await Promise.all(
      regions.map(async (region) => {
        try {
          const data = await fetchPoint(region.lat, region.lon, region.radiusNm);
          const aircraft = data.ac ?? [];

          const stored = await storeSnapshots(aircraft);

          const squawkDetections = detectSquawkTransitions(aircraft);
          if (squawkDetections.length > 0) await storeSquawkAlerts(squawkDetections);

          if (home) {
            const overflightDetections = detectOverflightTransitions(aircraft, home);
            if (overflightDetections.length > 0) await storeOverflights(overflightDetections);
          }

          console.log(`[poller] "${region.name}": ${aircraft.length} aircraft, ${stored} positions stored`);
        } catch (err) {
          if (err instanceof UpstreamError) {
            console.error(`[poller] region "${region.name}" fetch failed: ${err.message}`);
          } else {
            console.error(`[poller] region "${region.name}" unexpected error:`, err);
          }
        }
      })
    );
  } finally {
    pollInFlight = false;
  }
}

export async function startPoller(): Promise<void> {
  await ensureDefaultWatchRegion();
  await pollOnce();
  pollTimer = setInterval(() => {
    pollOnce().catch((err) => console.error("[poller] unhandled error in poll cycle:", err));
  }, env.POLL_INTERVAL_MS);
  console.log(`[poller] started, polling every ${env.POLL_INTERVAL_MS}ms`);
}

export function stopPoller(): void {
  if (pollTimer) {
    clearInterval(pollTimer);
    pollTimer = null;
  }
}

Wire it into index.ts: import startPoller/stopPoller, call await startPoller() after routes register, and call stopPoller() inside the existing shutdown handler.

Review This

Is there an actual re-entrancy guard, or does the AI trust every cycle to finish in time? A poller built without pollInFlight looks completely correct in local development, because a fast local Postgres and a responsive adsb.lol mean every cycle wraps up in well under fifteen seconds. It only breaks the day adsb.lol is slow, or a large multi-region fetch takes longer than usual, at which point setInterval fires the next tick anyway and two cycles start racing each other against the same activeSquawkAlerts map and currentlyOverflying set, corrupting the edge-triggered state both detectors depend on. Check loop.ts for a boolean flag checked and set at the top of pollOnce, returned early with a log line if it’s already true, and cleared in a finally block so a thrown error can’t leave it stuck true forever. If it’s missing, the fix is: “pollOnce needs a re-entrancy guard, a module-level flag that skips and logs the tick if the previous cycle is still running, cleared in a finally block.”

Does detection actually fire once per transition, or once per cycle the condition holds? This is the single easiest thing for an AI assistant to get subtly wrong in this entire lesson, because the naive version, “check if this aircraft is currently squawking an emergency code, and if so, record a detection,” reads as a completely reasonable implementation of “detect emergency squawks.” It compiles, it produces alerts, and in a five-second manual test where you set one aircraft to 7700 and check that an alert appeared, it looks exactly right. The bug only shows up if you leave that aircraft on 7700 for multiple poll cycles and count how many rows land in squawk_alerts. Read detectSquawkTransitions and detectOverflightTransitions for the wasAlerting/wasOverflying check gating each detections.push(...) call, not just an if (isEmergencySquawk(...)) with no memory of the previous cycle. If that check is missing, the fix is: “detectSquawkTransitions is firing every cycle an aircraft stays on an emergency squawk instead of once on the transition into it, add in-memory state tracking which hexes are currently alerting and only push a detection on the false-to-true transition.”

Does one region’s fetch failure actually stay contained, or does it take down the whole cycle? The prompt asked for per-region isolation, but it’s easy for an AI assistant to write regions.map(...) with the try/catch at the wrong scope, wrapping the whole Promise.all instead of each individual region’s callback, or to skip the try/catch inside the map entirely and let a thrown UpstreamError reject the whole Promise.all. With two enabled regions and both upstream calls succeeding, this looks identical to the correct version. It only shows a difference the moment one region’s fetch actually fails, at which point the broken version loses that cycle’s data for every region, not just the failing one. Confirm the try/catch sits inside the async (region) => { ... } callback passed to .map, not wrapped around the Promise.all call itself. If it’s in the wrong place, the fix is: “the try/catch around each region’s fetch/detect/store needs to be inside the per-region callback, not around the whole Promise.all, so one region’s upstream failure doesn’t lose every other region’s cycle.”

Try It

  1. Run the prompt above against your AI coding assistant, with the watch-regions resource from the previous lesson already in place.
  2. Read the poller and detection code against the three checks above before starting the server.
  3. Start the server and watch the logs. You should see a line like [poller] "New York (JFK area) - default": N aircraft, N positions stored roughly every fifteen seconds.
  4. Confirm rows are landing: psql $DATABASE_URL -c "SELECT count(*) FROM position_snapshots;", run twice a few seconds apart, and confirm the count grows.
  5. Insert a home location by hand near your watch region with a generous radius to exercise overflight detection without waiting for a real event:
    sql
    INSERT INTO home_location (id, lat, lon, radius_nm, altitude_ceiling_ft, updated_at)
    VALUES (1, 40.6413, -73.7781, 50, 20000, now());
    
  6. Watch overflight_log for new rows over several poll cycles while the same aircraft is inside the radius: psql $DATABASE_URL -c "SELECT * FROM overflight_log ORDER BY id DESC LIMIT 10;". Confirm the row count for a single lingering aircraft stops growing after its first appearance, it should not gain a new row every cycle it’s still inside the geofence.

Recap

  • A poller without a re-entrancy guard looks correct in every local test and only breaks the day an upstream call or a database write is slow enough for cycles to overlap. pollInFlight, checked at the top and cleared in a finally, is what prevents that.
  • Edge-triggered detection is the difference between one alert per real event and one alert per poll cycle that event is still true. Both versions compile and both produce alerts, so the only way to tell them apart is to watch what happens across multiple cycles, not just the first one.
  • Per-region try/catch has to sit inside the per-region callback, not around the whole Promise.all, or one bad upstream response takes an entire cycle’s data down with it instead of just that one region’s.

Next lesson: directing the real-time layer, positions and alerts pushed to the browser over WebSocket, and the two very different pub/sub mechanisms behind them.