Killing the copy-paste in blog-manager's syndication jobs
This was a follow-up from an earlier spike, and the premise was simple: eight background jobs in blog-manager had drifted into a copy-paste mess. Every job that posts something to Dev.to, Medium, or the newsletter, plus the GitHub blog scanner, carries the same Turbo broadcast skeleton. Three of them build a Listmonk client with the exact same six lines. And two of them, the poll jobs that check whether a Dev.to article or a Listmonk campaign actually went out, had quietly diverged in how they handle giving up.
What I was trying to do
Pull the duplicated pieces into shared code without changing how any of it behaves for the happy path, and fix the one real bug hiding in the duplication: Dev.to’s poll job stops silently after 12 attempts and leaves the post at devto_status: :draft forever, with no error and no way to retry from the UI. Newsletter’s equivalent poll job does the sane thing and marks itself failed with a message. Same shape, same constants, different ending.
What I built
Three pieces. Broadcastable, a job concern with one method, broadcast_record(record) - instead of hardcoding a partial path per job, it calls record.to_partial_path, the same primitive Rails’ own render record uses, which means the concern can’t drift out of sync with the view layer the way six separate string-built partial paths eventually would. PollJob, a base class both status-poll jobs now inherit from - it owns the loop, the backoff, the attempt cap, and the error policy, and each subclass only answers a handful of questions: is this record already resolved, how do you poll it, what does “failed” look like for this record, which errors are worth retrying versus giving up on immediately. And Syndication::ClientFactory, one module with a method per provider (devto, listmonk, medium_bridge) so the credential-reading code that used to live in five different build_client methods now lives in one place.
Decisions I made and why
The rescue ordering in PollJob almost bit me. Devto::Client::AuthError is a subclass of Devto::Client::Error, and I originally wrote the “retry on transient error” rescue clause before the “fail immediately on auth error” one. In Ruby, rescue clauses match top to bottom, so the broader Error clause would have silently swallowed every AuthError and rescheduled it 12 times instead of failing fast. I caught it by tracing through what “auth error” actually means at runtime, not by a test catching it, which is a little unsettling. I reordered so the more specific error class is checked first.
I also made the poll rescue not re-raise, on purpose, which is the opposite of what the publish jobs do. The publish jobs re-raise so ActiveJob’s retry_on/discard_on machinery can take over. But the poll jobs already run their own reschedule loop with their own attempt counter. If I let a rescued error also trigger retry_on, I’d have two independent retry mechanisms counting against the same problem, and the effective attempt cap would become meaningless.
I explicitly kept two things out of scope even though the original issue mentioned them: memoizing AppSetting.current (that’s its own ticket, and a naive memo would have broken every test that calls AppSetting.current.update! in setup), and renaming Post#article_entry to something clearer. Neither one needed to move for this refactor to land cleanly, and both would have widened the diff into unrelated territory.
What surprised me
How much the two poll jobs looked like twins right up until the moment they didn’t. Same MAX_ATTEMPTS = 12. Same backoff formula, copied character for character: 30 * (2**(attempt - 1)) capped at 15 minutes. If you diffed them side by side you’d see maybe four lines that actually differ. But one of those four lines is “what happens when you give up,” and that’s exactly the line nobody re-reviews once the first job ships and works. The second job gets written by copying the first, and the one place it should have copied faithfully is the one place someone changed something without meaning to send a signal.
The other surprise was smaller: the unit tests for these jobs assert on state changes and enqueued jobs, never on what actually gets rendered into the broadcast. A refactor that silently produced the wrong partial path would sail through bin/rails test with zero failures and only break in the browser. I ended up verifying to_partial_path for all three record types in a console, then firing one real broadcast per type and confirming a message actually landed in solid_cable, because that’s the only way to actually exercise the derivation the concern depends on.
What’s next
The follow-up ticket picks up the AppSetting memoization and a few other schema/code hygiene items. The Dev.to UI still shows “Publishing disabled” even for a post that’s now correctly marked failed after this change, which reads fine (the badge says ERROR, the text explains there’s no retry path yet) but a real retry button is still gated behind verifying native Dev.to posting end to end.
Related reading
Cleaning up a quarter's worth of dead code in one PR
Nine dead Postiz columns, a memoization where Rails.cache would have defeated the encryption, three Ruby filters moved into SQL, and unreachable code that wasn't.
The normalization bug that only shows up on tags made of nothing
A strip-based normalizer meets an all-punctuation tag: empty string as a hash key, wrong-tag substitution, and an autocomplete that matches everything. Three symptoms, one root cause.
The same button choice cost me a bigger bug than expected
Embedding the hero flow in the editor looked like the smaller option - until 'replace' met 166 real files that had never gone through the insertion-only path, and a migration with no backfill.