Fullscript Logo

Scaling Shopify's maintenance_tasks (Part 1): A Custom UI for the Gem

Author

Jess Johnson

Date Published

Share this post

Part 1 of 3 on what we built on top of Shopify's maintenance_tasks gem. Part 2 covers the real-time architecture, and Part 3 covers observability and automated cleanup. If your team runs data migrations or one-off scripts in production, there may be something here worth stealing.



For years our default tool for a one-off data change in production was a rake task, and rake tasks carry a stack of problems. Running one means finding someone with enough privilege to execute it on a specific pod, which is both a bottleneck and a wide security surface. Output lands wherever the queue's logs land, so finding the line or the error you care about is a scavenger hunt. And the long ones, the backfills that run for hours, quietly become a babysitting job for whoever watches for completion and reports it in Slack by hand.

Shopify's maintenance_tasks gem fixes most of that. It is a Rails engine where you define a collection of records and a process method that runs on each one, and it handles iteration, progress tracking, error recovery, and pause and resume. It is genuinely great at the hard part, running the work. It also ships a bundled UI that is functional and deliberately minimal.

This post is about the layer we built on top of that UI.

A maintenance-task UI isn't an admin convenience. It's the difference between "only the author, or whoever has production access, can safely run this" and "any engineer can." We got there by treating the gem's task data as an API and rendering our own front end on top of it.

The challenge: a great engine, but we quickly outgrew the UI

The stock UI works, but a few things kept biting us:

  1. Parameters were a minefield. Tasks can take arguments, and the default rendering is a plain text input per argument. That's fine until the argument is an enum with three valid values, or a boolean, or a date. Then someone types "True" or "2024-13-01" or a typo'd enum and only finds out after the run starts that the input was incorrect. For destructive operations, like deleting a few records by ID, a fat-fingered parameter is not a small thing.
  2. Secrets leaked into history. Some tasks take a credential as an argument. In a plain UI, that value gets echoed straight back into the run history, in plain text, for anyone with access to read later.
  3. There was no live feedback. Once you kicked off a run, the honest answer to "is it working?" was "...refresh and find out!" For a task chewing through hundreds of thousands of records, that's a lot of nervous refreshing.
  4. Tasks were written inconsistently. Across dozens of domains, every engineer wrote tasks a little differently and dropped them in slightly different places. Discoverability suffered, and so did the odds that the next person could find and run the right one.

The solution: treat task data as an API

The move that made everything else possible was to stop theming the gem's UI and start serializing the gem's data. The gem already knows everything about a task: its name, parameters, run history, and the live status of any active run. We added a thin controller and a serializer that expose that as JSON, then rendered the whole interface as a single-page app in our own design system. Reads flow through our serializer. Writes (running, pausing, cancelling) still go through the gem's own engine routes.

We didn't reimplement execution. We rebuilt the interface around it.

In the codebase there is a controller feeding a serializer, which feeds a React app. The serializer shape is roughly:

1
2def serialize_show(task)
3 {
4 name: task.name,
5 parameters: task.parameter_names.map { |p| describe_param(task, p) },
6 activeRuns: task.active_runs.map { |r| serialize_run(r) },
7 completedRuns: task.completed_runs.map { |r| serialize_run(r) },
8 }
9end

This serializer is the unsung hero of the whole project. It's a stable seam between the gem's internals and our UI. When we later added real-time updates (Part 2), the UI didn't care because it was already speaking JSON. When we added richer parameters, we changed one method. A clean boundary early on bought us a lot of freedom later.

The components on the other side are what you'd expect: a task list, a run table, a run detail panel, status pills, a parameter form, and a few quality-of-life touches (like inline tips). Nothing exotic. The point is that they're ours, consistent with the rest of our admin, and free to evolve independently of the gem.

Make parameters safe by construction

Free-text inputs are where runs were going wrong, so parameters got the most attention.

Instead of rendering every argument as a text box, the serializer enriches each parameter with enough metadata for the form to render the right control:

  • Type. A boolean becomes a checkbox, an enum becomes a dropdown, a date becomes a date picker, and so on.
  • Whether it's required. Derived from the task's presence validators, so the form can block an empty submit before it ever reaches the engine.
  • Hints. Optional help text shown inline, so "what does 'mode' actually do?" is answered right next to the field.
  • Allowed values. Pulled from inclusion validators to populate dropdowns.
  • Masked secrets. Flagged arguments are filtered everywhere they'd otherwise appear, including run history.

From the task author's side, this is mostly declarative:

1
2class BackfillSomethingTask < ApplicationTask
3 attribute :mode, :string
4 validates :mode, inclusion: { in: %w[dry_run commit] } # renders a dropdown
5 def self.parameter_hints = { mode: "dry_run logs only; commit writes changes" }
6 def self.masked_arguments = [:api_key] # filtered everywhere
7end

In the interest of honesty: not all of this is our invention. The gem itself supports masking arguments and reading inclusion values. What we added is the typed form rendering, inline hints, the ability to hide certain parameters from the admin form entirely, and the serialization that ties it together so the front end can act on it. The combination is what makes a parameter safe by construction rather than safe by careful typing.

Keep authoring consistent

Two small things keep the whole thing coherent. A custom generator scaffolds new tasks into the right domain-scoped path with a predictable shape, and a RuboCop cop enforces, and auto-corrects, that every task inherits from our base class. That base class is where most of Part 2 and Part 3 live, so the cop quietly guarantees every task gets the full treatment.

Tradeoffs

We started by theming the gem's ERB views and gave up. The templates were never meant to be extended the way we needed, and every richer control became another fragile monkey-patch until the maintenance burden outweighed the savings. That is when we rebuilt the front end with React. The cost is real. We now run a websocket layer (Part 2) and keep the serializer in step with each gem upgrade. We took it because the ceiling was so much higher, and the serializer boundary has absorbed every upgrade since without the React side noticing.

What it got us, and when it's worth it

Today the system runs about 250 maintenance tasks across 28 domains, all discoverable and runnable from one UI. Any engineer can run a task from a browser, not just its author or whoever holds production access, because the UI carries the types, hints, validation, and live status that make a run self-explanatory. Secrets no longer appear in run history, and new tasks land where the next person can find them.

If your task volume is small, or each task is only ever run by its own author, the stock UI is fine. This pays off when many engineers across many teams need to run each other's tasks safely without elevated production access.

Conclusion

The gem solved the hard part, running the work reliably. The thing standing between that engine and a genuinely great experience was everything wrapped around it. By treating task data as an API and building our own UI on top, we turned a developer utility into something any engineer can operate quickly and safely.

And those live progress bars in the screenshots? They aren't polling. Part 2 is about how we modify a gem we don't own to broadcast real-time updates without forking it.

Share this post