Heartbeats were polluting my activity table
The phone in a side-project safety app posts a stationary_heartbeat
every few minutes when it's not moving — so the server knows the
device is alive even when there's no user activity. The original
implementation wrote those heartbeats into the same DynamoDB
activities table as real user events. That broke a downstream
sleep-window learner that was supposed to detect quiet gaps. With
heartbeats every five minutes there were no quiet gaps.
What was happening
The "last activity" query in the inactivity cron was reading the
most recent row of checkonmine_activities and computing a gap
from it. Heartbeats were rows in that table. So the gap was
almost always five minutes, and the sleep-window learner saw a
24/7 firehose of low-amplitude events instead of any natural
overnight quiet.
The throttle in api.php did one rate-limit check per event_type,
which meant heartbeats also stomped on the throttle for real
events.
What I found
The two kinds of writes have different shapes:
- A real activity event ("user opened app", "user tapped check in", "geofence enter") is sparse and you want to keep them forever for pattern learning.
- A heartbeat is dense, low-value, and only matters for "is the device still alive?" — a 90-day TTL is plenty.
Putting both in one table conflates two access patterns and breaks both. The fix is to split the table.
The fix
New table:
aws dynamodb create-table \
--table-name checkonmine_device_pings \
--attribute-definitions \
AttributeName=user_id,AttributeType=S \
AttributeName=timestamp,AttributeType=S \
--key-schema \
AttributeName=user_id,KeyType=HASH \
AttributeName=timestamp,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--region us-east-1
aws dynamodb update-time-to-live \
--table-name checkonmine_device_pings \
--time-to-live-specification "Enabled=true,AttributeName=ttl"
And the router branch in api.php:
if ($event === 'stationary_heartbeat') {
$table = 'checkonmine_device_pings';
$item['ttl'] = time() + 90 * 86400;
} else {
$table = 'checkonmine_activities';
}
$db->put($table, $item);
The critical-health-alert pipeline still runs for heartbeats — the payload (battery, GPS quality, etc.) is evaluated in-memory before the row is written. So the cheap-storage move doesn't lose anything.
Verified with md5 hash checks across all 10 Lambda functions post-deploy: zero drift between functions or from the source repo.
What I'd do differently
The original "write everything to one table" decision was made
before the sleep-window learner existed. The learner was bolted
on later, looked at the same table, and silently produced
garbage. That's the lesson: when you add a feature that depends
on a property of existing data, write down what property you're
betting on. "Quiet stretches in activities correspond to user
sleep" was a hidden assumption that broke the day someone added
heartbeats.
If I'd been on the ball at the time, the cleanest pattern would
have been an is_heartbeat boolean on each row with a GSI that
excludes heartbeats. Same effect, fewer table-management chores,
and the TTL goes on the same table. I might still migrate that
direction.