Every so often someone pauses a monitor — a staging box they're rebuilding, a client site that churned, a side project they lost interest in — and never comes back to it. The monitor sits there, paused, doing nothing, forever. Which sounds harmless, and mostly is, right up until you're keeping check history and table rows around for thousands of things nobody will ever look at again.
Added a graduated cleanup for monitors that have been paused a long time: a heads-up email at day 25, a visible "this is going away" treatment on the dashboard from day 25 onward, a final warning at day 85, and hard deletion at day 90. The clock is per-monitor and resets the moment you turn one back on. Fully-idle accounts are explicitly out of scope — this only ever touches individual paused monitors, never a login.
Why not just delete them
The easy version of this feature is one line of SQL on a cron: anything paused more than N days, drop it. The reason that's the wrong version is that "paused" doesn't mean "abandoned." Plenty of people pause a monitor on purpose and mean to come back — during a migration, over a holiday, while a cert is reissued. Silently deleting one of those, along with its uptime history, is the kind of thing that makes someone stop trusting the tool entirely.
So the constraint wasn't "clean up old monitors." It was clean up old monitors such that nobody is ever surprised by it — which turns a one-liner into a small state machine with a generous runway and a few chances to opt back in.
The clock: one timestamp, reset on reactivation
Everything keys off a single column, paused_at, stamped automatically by a trigger whenever a monitor's is_active flips from true to false — and cleared again (along with any warning flags) the moment it flips back:
create function handle_monitor_active_change() returns trigger as $$
begin
if new.is_active = false and old.is_active = true then
new.paused_at := now();
new.inactivity_warning_sent_at := null;
new.inactivity_final_warning_sent_at := null;
elsif new.is_active = true and old.is_active = false then
new.paused_at := null;
new.inactivity_warning_sent_at := null;
new.inactivity_final_warning_sent_at := null;
end if;
return new;
end;
$$ language plpgsql;The "reset on reactivation" half matters more than it looks. It means a monitor you pause, ignore for a month, then switch back on for a day and pause again gets a fresh 90 days, not whatever was left of the old countdown. The only way to actually reach deletion is to leave something paused and untouched for the entire window.
This is data hygiene, not plan enforcement. It runs the same on every tier, and it's completely separate from the downgrade flow (which pauses monitors when you drop below your new plan's limit and hands you a "pick which ones to keep active" screen). A monitor paused by a downgrade gets the same 90-day runway as one you paused yourself — the cleanup can't tell the difference and doesn't need to.
The three stages
- Day 25 — heads-up email. "This monitor's been paused for 25 days. It'll be removed in 65 days unless you turn it back on." One email, flagged so it never re-sends for the same pause.
- Day 25 onward — visible on the dashboard. The monitor's row gets struck through, dimmed to 60%, and picks up a small clock icon you can hover for the exact wording: "Paused for N days — will be removed in M days unless reactivated," with M computed live off the real day-90 point rather than a stored guess. The email is easy to miss; the dashboard treatment is not.
- Day 85 — final warning email. Same channel, more urgent copy, five days of runway left.
- Day 90 — deletion. The monitor row is removed; its
uptime_logsgo with it through the existingon delete cascade.
Twenty-five days before the first nudge might read as slow. It's deliberate — the common "I'll pause this over the holidays" case should never generate an email at all, and two weeks wasn't enough margin for that.
Testing the part that deletes things
The stage that removes rows is the one you want to be sure about, and "trust the WHERE clause" isn't a testing strategy. Each stage's exact query got run against scratch monitors with paused_at backdated to 10, 26, 86, and 91 days — one row just short of each threshold, one just past — and checked that every query picked up exactly the rows it should and left the others alone. The read-only SELECT versions first, mirroring the real statements, before anything ran that actually mutated.
The cascade got the same treatment: a real monitor, a batch of uptime_logs rows attached to it, delete the monitor, recount the logs. Zero. The foreign key does what the schema says it does — but now that's something observed, not assumed.
A destructive job you haven't watched run against throwaway data is a plan, not a feature. The gap between "the query looks right" and "the query did the right thing" is exactly where the incident lives.
What's deliberately not covered
A completely idle account — someone who signed up, created nothing, and never returned — gets no parallel cleanup. That's a different problem with a different blast radius (you'd be deleting a login, not a row), and folding it into this job would have meant reasoning about both at once. Per-monitor only, for now.
We also considered a shorter clock for Free-tier monitors and decided against it for the first version. One 90-day number for everyone is easier to explain, easier to get right, and easy to revisit if it turns out to matter in practice. It hasn't yet.
The takeaway
The cleanup logic is trivial. The design work was entirely in the margins around it: a long enough runway that a normal pause never trips it, a clock that resets on any sign of life, two separate warnings on two surfaces, and a visible countdown so the deletion is never the first you hear of it. "Remove abandoned data" is one line. "Remove abandoned data without ever making someone regret trusting you with it" is the actual job.