Auto-detecting dead feeds with an Oban cron worker

· · 30 views
Table of Contents

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.