Feeds don't die loudly. A blog goes quiet, a domain lapses, a WordPress plugin breaks the RSS endpoint — and nothing throws an error. The fetch still returns 200, the parser still runs, we just stop seeing new entries. From the outside everything looks healthy right up until a user asks why their favourite site hasn't updated in two months.
I wanted something that surfaced these zombies without me babysitting a dashboard. The result is DeadFeedTicketWorker: a weekly cron job that scans for feeds that have gone silent and opens a ticket on our internal Kanban board for each one. The nice part is what happens next — when a dead feed comes back to life, the ticket resolves itself. No human closes it.
When it runs: cron, not events
The obvious instinct is to make death detection event-driven — flag a feed the moment a fetch fails. But "no new entries" isn't an event. It's the absence of events over a window, and you can't subscribe to something not happening. So this is squarely a batch job.
I wired it into the Oban cron plugin. It runs weekly, Monday at 09:00 UTC:
{Oban.Plugins.Cron,
crontab: [
# ...
{"0 9 * * 1", BoringRSS.Workers.DeadFeedTicketWorker},
# ...
]}
Weekly is deliberate. A dead feed is a slow-burn problem — checking hourly would just burn cycles re-confirming the same silence. The worker itself is a plain Oban worker on our existing fetch queue:
use Oban.Worker, queue: :feed_fetch, max_attempts: 3
The recovery direction, by contrast, is event-driven — more on that below. So the system is deliberately asymmetric: death is polled, resurrection is pushed.
Deciding a feed is "dead"
The definition lives in Feeds.dead_feeds/1, and the worker just picks the threshold:
@dead_days 30
# ...
dead_feeds = Feeds.dead_feeds(@dead_days)
The query is the interesting bit, because "dead" needs a guard against false positives. A brand-new feed we added yesterday has no recent entries either, but it isn't dead — it just hasn't been polled enough. So dead_feeds/1 requires a feed to have published something at some point, then checks it published nothing in the window:
def dead_feeds(days \\ 30) when is_integer(days) and days > 0 do
since = DateTime.add(DateTime.utc_now(), -days, :day)
has_any_entry =
from(e in Entry, select: e.feed_id, distinct: true)
has_recent_entry =
from(e in Entry,
where: e.inserted_at >= ^since,
select: e.feed_id,
distinct: true
)
from(f in Feed,
where: f.id in subquery(has_any_entry),
where: f.id not in subquery(has_recent_entry)
)
|> Repo.all()
end
The has_any_entry subquery is the grace period. Without it, every empty feed in the database would light up as dead. With it, a feed only qualifies once it has a track record — it published before, and then stopped for 30 days. That single IN subquery / NOT IN subquery pair does the whole job and keeps the logic in the database instead of in Elixir memory.
Thirty days is a judgement call. Plenty of legitimate blogs post less than monthly, so this threshold will occasionally flag a feed that's merely slow rather than dead. I'm fine with that: a ticket is cheap, and a false positive costs a glance, not an outage.
What it files: a Board ticket
For each dead feed the worker calls Board.ensure_ticket/3:
Enum.map(dead_feeds, fn feed ->
case Board.ensure_ticket("dead_feed", feed.id, %{
"title" => "Dead feed: #{feed.title || feed.url}",
"priority" => "medium",
"description" => "No new entries in #{@dead_days} days.\n\nURL: #{feed.url}"
}) do
{:ok, :created, _ticket} -> :created
{:ok, :existing, _ticket} -> :existing
{:error, reason} ->
Logger.warning(
"[#{__MODULE__}] Failed to create ticket for feed #{feed.id}: #{inspect(reason)}"
)
:error
end
end)
The three-way return is the whole ergonomic win. ensure_ticket/3 tells you whether it created a ticket or found an existing one, so the worker can tally created / existing / errors and log a one-line summary per run without any of its own bookkeeping. Failures are logged and counted, never raised — one feed with a malformed title shouldn't abort the scan for the other 400.
Idempotency: why re-runs don't spam the board
Here's the part I care about most. This worker runs every week, and most dead feeds stay dead for a long time. A naive implementation would file the same ticket 52 times a year. ensure_ticket/3 makes that impossible by deduplicating on source_type plus source_ref_id:
def ensure_ticket(source_type, source_ref_id, attrs)
when is_binary(source_type) and is_binary(source_ref_id) do
case Repo.get_by(Ticket, source_type: source_type, source_ref_id: source_ref_id) do
%Ticket{} = existing -> {:ok, :existing, existing}
nil -> do_create_system_ticket(source_type, source_ref_id, attrs)
end
end
We pass "dead_feed" as the type and the feed's id as the ref, so the pair uniquely identifies "the dead-feed ticket for this feed." The second and subsequent runs see the existing row and return {:ok, :existing, _}. The board stays clean; the worker stays dumb. That design generalises nicely — the same function backs other system-generated cards, and every one of them inherits duplicate-proof creation for free. New tickets land in the leftmost column with source: "system" so they're visually distinct from human-created work.
Auto-resolution: closing the loop
A monitoring signal that only fires but never clears is noise. If someone had to manually close every dead-feed ticket the moment a site came back, the board would rot into a graveyard of stale cards.
So resolution is pushed, not polled. It hangs off the parser's hot path — the exact moment a genuinely new entry lands:
{:new, entry} ->
broadcast_new_entry(feed_id, entry)
BoringRSS.Board.resolve_tickets_for(feed_id)
# ...
Note that guard is {:new, entry} specifically. Feeds without ETag or Last-Modified support re-send the same items every poll; firing on :updated or :unchanged would resolve tickets on feeds that are still effectively dead. Only a truly new entry counts as a pulse.
resolve_tickets_for/1 then sweeps every open ticket for that feed in one update_all:
def resolve_tickets_for(source_ref_id) when is_binary(source_ref_id) do
resolved_col_id = resolved_column_id()
if resolved_col_id do
now = DateTime.utc_now() |> DateTime.truncate(:second)
{count, _} =
from(t in Ticket,
where: t.source_ref_id == ^source_ref_id and is_nil(t.resolved_at)
)
|> Repo.update_all(set: [column_id: resolved_col_id, resolved_at: now, updated_at: now])
{:ok, count}
else
{:ok, 0}
end
end
The is_nil(t.resolved_at) filter keeps it idempotent from the other side — already-resolved tickets are skipped, so re-firing costs nothing. And because it keys on source_ref_id alone, one recovering feed clears whatever system tickets reference it in a single statement.
What I'd change
The threshold is a hard-coded module attribute; some publishers genuinely post quarterly and will keep tripping it. A per-feed cadence baseline would be smarter than one global @dead_days. But that's a refinement, not a rewrite — the shape is right. Poll for absence, push for recovery, and let an idempotent ensure_ticket absorb every duplicate so the worker never has to think about state. It's boring, and that's the point.