Skip to content
Development

Global tag operations, and the concurrency guard that wasn't

By Victor Da Luz
railsrubydev-logblog-manager

This one had been sitting in the backlog since it was carved out of a bigger post-editor spec months ago: a tag index with post counts, plus rename/merge across every post on a blog. Low priority, marked “v2.” I picked it up mostly because it looked self-contained.

It wasn’t, in the interesting way - the issue text itself pointed at a dependency that turned out not to be real, and the actual implementation surfaced a genuine gap in how I’d been reasoning about background job safety.

The dependency that wasn’t

The issue said renames go “through the editor write path.” There’s a whole separate issue that owns exactly that - a single-post interactive commit button, still sitting blocked. Read literally, this one looked like it couldn’t start until that shipped.

I actually read what that blocked issue is FOR before accepting that. It’s specifically about resolving “did the file change on GitHub while a human was staring at the editor mid-edit” - it needs a stored base_file_sha on a draft record precisely because a browser session spans multiple requests and the file could drift in between. A batch rename has none of that problem. It fetches a file’s current sha and writes it back in the same synchronous job execution - no multi-request gap for anything to drift across. The write primitives it actually needs (a lossless frontmatter rewriter, a sha-based conflict-checked GitHub write) already existed and were already proven to work without any draft model, in the exact shape the hero-image commit feature had shipped earlier.

So the issue’s own framing was slightly wrong, and taking it at face value would have meant either building nothing (waiting on a blocked ticket) or accidentally reinventing that ticket’s scope inside a batch job that didn’t need it. Worth the extra twenty minutes of reading before writing any code.

The design I almost shipped wrong

Renaming a tag across N posts means N separate GitHub writes. Some could fail - a stale sha, a network blip, whatever. I designed this as a single job that loops over every affected post, catches each post’s failure individually, and keeps going - so one bad file doesn’t block the other 39. At the end it logs a summary and moves on.

That part was fine. What I got wrong: the job never re-raised anything. A batch where 2 of 40 posts failed looked, from the outside, identical to a batch where all 40 succeeded - a normal green “completed” entry in the job dashboard. The only trace of the failure was a log line nobody had any reason to go looking for.

I’d explicitly decided during planning that partial failure was an acceptable v1 tradeoff as long as “re-run the operation” was a real recovery path. Code review pointed out, correctly, that a recovery path only works if something tells you it’s needed. I’d built the “safe to re-run” half and quietly dropped the “how would anyone know” half. Fixed by having the job raise a summary exception after the loop if anything failed - still never interrupting the loop itself, just surfacing at the end so the job shows up as failed/retrying like every other job in this app does. Free automatic retry came along with it, which turned out to matter more than I expected (next section).

The concurrency guard that wasn’t actually a guard

This is the one I’m most annoyed I didn’t check earlier. Two different jobs write to the same blog’s GitHub repo now - the existing hero-image-commit job, and this new tag-rename job. I gave both the same limits_concurrency key, assuming that would serialize them against each other so they’d never race on the same file.

They don’t. I only found this because a review angle went and read the actual gem source instead of trusting the surface-level API. Rails’ concurrency key is really [group, key].join, and the group defaults to the job’s own class name unless you say otherwise. Same key string, different job classes, different groups, different final key - no shared lock at all. My whole assumption was wrong, and nothing about the code would have told me; it silently does something other than what it looks like it does.

The actual safety net the whole time was GitHub’s own conflict check on the file’s sha - real, and sufficient to prevent corruption, but not what I’d designed around, and not something my job was even set up to retry on (that gap got closed by the same fix as the previous section, since a sha conflict is exactly the kind of transient failure the new retry now covers). Fixed with an explicit shared group between the two jobs so they actually share one slot per blog now, matching what I’d originally intended.

Smaller ones

Tag names come from free-text frontmatter, so nothing stops one from containing a slash. I’d put the tag name directly in a URL path segment for the rename route - which meant the entire tags page would crash the moment any post had a tag like “ci/cd”, not just that row’s rename button. Moved the rename to a fixed URL with the tag name in the request body instead of the path, which sidesteps the whole class of “what characters break routing” problem.

Also merged three near-identical copies of the same “check the locale-prefixed path first, fall back to the flat path” file-lookup logic - two of them had comments literally saying “mirrors X’s version” without anyone extracting it. Third time really is the charm for finally doing it.

What surprised me

Testing this against real (sanitized, dev) data instead of just fixtures paid off immediately - the actual vdaluz.com content has 465 distinct tags, including a live “Ansible”/“ansible” casing duplicate sitting right there, which became the natural test case for the merge behavior instead of something synthetic. It also surfaced the slash-in-tag risk empirically rather than theoretically, since I could see the real shape of the data before committing to a URL design.

What’s next

Nothing else queued for this specific issue. If a future need wants the DB-recorded tags to track live-file drift more rigorously, or wants cross-tab visibility into an in-progress rename, those are extensions on top of this, not corrections to it.

Related reading

Development

The editor commit button is a deploy button

Committing a draft to main auto-deploys the blog. Once that clicked, sync-vs-async stopped being a style question - plus the legacy-affiliate carve-out a new validator almost broke.

Read