Windowed Aggregation of AIS Positions with Kinesis
Windowed AIS aggregation assigns each vessel position to a geohash cell and an event-time window, then maintains a per-(cell, window) aggregate — vessel count and average SOG — in DynamoDB using atomic ADD updates. Use tumbling windows for periodic snapshots and sliding windows for rolling metrics, bucket by the report’s own timestamp rather than arrival time, and hold windows open until a watermark (max event time minus an allowed-lateness grace period) passes their end so out-of-order reports still land in the right bucket.
Context
The real-time AIS vessel tracking pipeline writes one row per raw position report, which is perfect for “where is this vessel now” but overwhelming for questions like “how many vessels are transiting each grid cell per minute” or “what is the average speed in the approach channel.” Aggregation answers those questions and, as a bonus, collapses thousands of raw reports into a handful of per-cell summaries, cutting downstream storage and query cost dramatically. This is the streaming counterpart to the file-batched summarization discussed in batch vs stream geospatial processing: here the window is the unit of work, not the file.
The hard part is time. AIS reports do not arrive in perfect order — satellite relay, TCP buffering, and multi-source fusion all reorder them by seconds to minutes. If you bucket by processing time (when the record reached your consumer) your counts smear across window boundaries. Bucketing by event time (the report’s own timestamp) fixes the counts but forces you to decide how long to wait for stragglers, which is exactly what a watermark encodes.
Prerequisites
Before deploying the aggregator, confirm the following:
- Runtime: Python 3.11 on AWS Lambda, bound to the Kinesis stream via an event source mapping with
ReportBatchItemFailuresenabled - IAM:
kinesis:GetRecords,kinesis:GetShardIterator,kinesis:DescribeStreamon the source stream;dynamodb:UpdateItemanddynamodb:Queryon the aggregate table - State table: DynamoDB
ais_window_aggwith partition keycell(geohash string) and sort keywindow_start(epoch seconds), plus numeric attributescountandsog_sum - Dependencies:
pygeohash==1.2.0for cell encoding; no native GDAL is required, so the layer stays small and cold starts stay short - Environment variables:
WINDOW_SECONDS=60 # tumbling window width ALLOWED_LATENESS_SECONDS=120 # grace period before a window is final GEOHASH_PRECISION=6 # ~1.2 km cells for density; raise for finer grids AGG_TABLE=ais_window_agg - A working understanding of the per-MMSI ordering guarantees from the parent pipeline — ordering is preserved per shard, but reports from different vessels still interleave.
Implementation
The handler assigns each report to a (cell, window_start) key, merges partial aggregates within the batch to minimize DynamoDB calls, and applies the watermark to decide which reports are too late to count. State lives in DynamoDB via atomic ADD, so concurrent shard consumers never clobber each other’s counts.
# aggregator.py — event-time windowed AIS aggregation on Kinesis + Lambda
import base64
import os
from collections import defaultdict
import boto3
import pygeohash as pgh
from pyais import decode
WINDOW = int(os.environ["WINDOW_SECONDS"])
LATENESS = int(os.environ["ALLOWED_LATENESS_SECONDS"])
PRECISION = int(os.environ["GEOHASH_PRECISION"])
POSITION_TYPES = {1, 2, 3, 18, 19}
_table = boto3.resource("dynamodb").Table(os.environ["AGG_TABLE"])
def window_start(ts: int) -> int:
"""Floor an epoch-second timestamp to its tumbling window boundary."""
return ts - (ts % WINDOW)
def handler(event, context):
# Track the max event time in this batch to advance the watermark.
max_event_ts = 0
# Partial aggregates merged in-memory before the durable write:
# (cell, window_start) -> [count, sog_sum]
partials: dict[tuple[str, int], list] = defaultdict(lambda: [0, 0.0])
failures = []
late_side_output = []
for rec in event["Records"]:
seq = rec["kinesis"]["sequenceNumber"]
try:
raw = base64.b64decode(rec["kinesis"]["data"]).decode("utf-8")
msg = decode(raw)
if msg.msg_type not in POSITION_TYPES:
continue
# Prefer the report's own event time; fall back to arrival time.
ts = int(getattr(msg, "epoch", rec["kinesis"]["approximateArrivalTimestamp"]))
max_event_ts = max(max_event_ts, ts)
cell = pgh.encode(float(msg.lat), float(msg.lon), precision=PRECISION)
w_start = window_start(ts)
sog = float(getattr(msg, "speed", 0.0))
agg = partials[(cell, w_start)]
agg[0] += 1 # vessel report count
agg[1] += sog # running SOG sum -> average on read
except Exception as exc:
failures.append({"itemIdentifier": seq})
# Watermark: the frontier of event time we consider "complete".
watermark = max_event_ts - LATENESS
for (cell, w_start), (count, sog_sum) in partials.items():
window_end = w_start + WINDOW
if window_end <= watermark:
# Window has already been closed and flushed by a prior batch;
# this report is past the allowed lateness -> side-output.
late_side_output.append({"cell": cell, "window_start": w_start, "count": count})
continue
# Atomic merge into durable state; safe across concurrent shard workers.
_table.update_item(
Key={"cell": cell, "window_start": w_start},
UpdateExpression="ADD #c :c, sog_sum :s",
ExpressionAttributeNames={"#c": "count"},
ExpressionAttributeValues={":c": count, ":s": _dec(sog_sum)},
)
if late_side_output:
_emit_late(late_side_output) # e.g. to a reconciliation queue
# Only genuinely un-decodable records are retried / dead-lettered.
return {"batchItemFailures": failures}
def _dec(x: float):
from decimal import Decimal
return Decimal(str(round(x, 3))) # DynamoDB requires Decimal, not float
def _emit_late(items: list) -> None:
# Route late reports somewhere for optional backfill; do not block the shard.
pass
To read a closed window’s average SOG, query by cell for the window_start and compute sog_sum / count at read time — storing the sum rather than a running average keeps the atomic ADD correct under concurrency. For sliding windows, emit each report into every overlapping window key (for a 15-minute window sliding every minute, that is 15 keys) inside the same loop; the state and write volume grow by the overlap factor, so reserve sliding windows for metrics that truly need continuous refresh.
For workloads where hand-rolled windowing becomes unwieldy — session windows, complex event-time triggers, or exactly-once sinks — move the aggregation to Kinesis Data Analytics (Amazon Managed Service for Apache Flink), which provides watermarks, keyed state, and windowing as first-class runtime features. The Lambda approach here is the right choice when the aggregation is simple counts and sums and you want to stay fully serverless without a Flink cluster. The upstream deduplication and idempotency concerns are the same ones covered in deduplicating S3 event notifications for idempotent ingestion, since a redelivered Kinesis record would otherwise double-count.
Verification
Replay a fixed set of reports with known timestamps and confirm the per-cell counts land in the expected windows, including a deliberately late report.
# verify_windows.py — assert counts per (cell, window) after a replay
import boto3
table = boto3.resource("dynamodb").Table("ais_window_agg")
resp = table.query(
KeyConditionExpression="cell = :c",
ExpressionAttributeValues={":c": "u4pruy"},
)
for item in resp["Items"]:
avg_sog = float(item["sog_sum"]) / int(item["count"])
print(f"window {item['window_start']}: count={item['count']} avg_sog={avg_sog:.1f}")
Expected output for a replay of the diagram’s scenario (a late report correctly counted into window W0):
window 0: count=12 avg_sog=9.4
window 60: count=15 avg_sog=8.1
window 120: count=7 avg_sog=10.2
If the late report instead inflates the current window’s count, your handler is bucketing by processing time — switch the timestamp source to the report’s event time.
Gotchas and Edge Cases
- DynamoDB rejects
float. Numeric attributes must beDecimal. Passing a Pythonfloattoupdate_itemraisesTypeError: Float types are not supported. Convert SOG sums withDecimal(str(value)), neverDecimal(value), to avoid binary-float precision artifacts. - Watermark stalls when the feed goes quiet. The watermark is derived from the max event time seen; if a shard receives no records, its watermark never advances and windows never close. Add a periodic tick (an EventBridge-scheduled invocation) that advances the watermark using wall-clock time so idle cells still finalize.
- Sliding windows multiply state and cost. A 15-minute window sliding every minute writes each report into 15 keys. Confirm the overlap factor is worth it; if consumers only ever read the latest complete window, a tumbling window plus a small rolling read is far cheaper.
- Redelivery double-counts. Kinesis guarantees at-least-once delivery, so a retried batch re-adds counts. Make writes idempotent by keying on a per-batch dedupe token, or accept small over-counts on retries if the metric is a density estimate rather than an exact tally.
Frequently Asked Questions
Should I use tumbling or sliding windows for AIS traffic density?
Use tumbling (non-overlapping) windows for periodic snapshots like a per-minute vessel count per cell — each report contributes to exactly one window, which keeps the math and storage simple. Use sliding windows when you need a continuously updated rolling metric, such as a 15-minute average SOG refreshed every minute; sliding windows overlap, so each report contributes to several windows and both compute and state grow with the overlap factor.
How do I handle AIS reports that arrive out of order?
Aggregate by event time — the report’s own timestamp — not processing time. Floor each report to its window boundary and keep windows open until a watermark (the max event time seen minus an allowed-lateness grace period) passes their end. Reports arriving after the watermark closes their window are past the allowed lateness and are dropped or routed to a late side-output for reconciliation.
Where do I keep window state between Lambda invocations?
Lambda is stateless across invocations, so window aggregates must live in an external store. Use a DynamoDB table keyed by (geohash_cell, window_start) and update counts and running SOG sums with atomic ADD expressions. For heavier windowing with built-in state management, use Kinesis Data Analytics (Managed Service for Apache Flink), which keeps window state in the Flink runtime.
Related
- Real-Time AIS Vessel Tracking Pipeline — the end-to-end ingest, decode, and store pipeline this aggregation feeds from
- Batch vs Stream Geospatial Processing — when a windowed stream beats a periodic batch summary
- Implementing Dead-Letter Queues for Failed Vector Jobs — capture undecodable records without stalling the aggregator
- Deduplicating S3 Event Notifications for Idempotent Ingestion — the idempotency techniques that stop redelivered records from double-counting
- Memory and CPU Allocation for Raster Workloads — size the consumer so the per-batch aggregation keeps pace with ingest