The Accidental Application Runtime

theanonymousone1 pts0 comments

The Accidental Application Runtime · Michael-F-Bryan<br>I often like exploring a topic in great depth and writing about my<br>thoughts and experiences as I go along.<br>This is more of an extended essay than an article to be read in a<br>single sitting. Feel free to read it one piece at a time, or just<br>skip to the bits that look interesting.<br>Here, have a Table of Contents:

Imagine you look after the software for a small 3D-print farm: a rack of printers and a Go service running on a box in the corner. The service started life as a dashboard. Somebody wanted to see what each printer was doing without walking over to it, so you wrote a little HTTP server, and because the handlers needed live printer state, the server&rsquo;s setup code connected to the printers and started a goroutine to poll them. That was completely reasonable.<br>Then the central backend came along. Jobs now arrive from upstream, so the service grew a goroutine that pulls them down into a SQLite-backed job store. Something has to match queued jobs to idle printers, so it grew a scheduler. Something has to actually send G-code to a printer and watch it happen, so it grew a runner. Each of these landed in the same setup path, because that&rsquo;s where the printer connections and the store already lived, and each was a small, sensible change that took an afternoon.<br>A while later the backend team asked for periodic status reports, and by then nobody even paused before adding another goroutine to the pile. The setup code now looks something like this:<br>func NewServer(cfg Config) (*Server, error) {<br>printers, err := ConnectPrinters(cfg.Printers)<br>if err != nil {<br>return nil, err

store, err := OpenJobStore(cfg.DatabasePath)<br>if err != nil {<br>return nil, err

s := &Server{<br>printers: printers,<br>store: store,<br>backend: NewBackendClient(cfg.BackendURL, cfg.APIKey),<br>state: newStateCache(),

go s.watchPrinters() // the dashboard needs live state<br>go s.pullJobs() // jobs arrive from the backend<br>go s.schedule() // idle printer + queued job → assignment<br>go s.runAssignments() // someone has to actually print things<br>go s.reportUpstream() // the backend wants status reports

return s, nil

This code isn&rsquo;t obviously bad. Each line was a reasonable next step, and the application does print things. The trouble is that the setup code now owns all of the application&rsquo;s long-running work.<br>Then Someone Asks for Graceful Shutdown

Link to heading<br>The problem showed up when somebody asked for graceful shutdown. Deploying a new version of the service means killing the process, and killing the process mid-print ruins whatever was on the beds and leaves half-claimed jobs in limbo. The request is simple enough: when the service receives SIGTERM, it should stop accepting new work, let the runner get each printer to a safe stopping point, record which assignments were interrupted so the next process can pick them up, and then exit.<br>You sit down to thread cancellation through the code, and the questions start piling up. Which goroutines are even running? The only way to answer is to read the setup code and everything it calls. What order should they stop in? The scheduler feeds the runner through a channel. If the scheduler exits first, who closes it, and is the runner allowed to finish draining it? The puller and the reporter both talk to the backend; do they share a shutdown deadline? Every background failure so far has just been logged and forgotten, and now some of them are supposed to trigger an orderly teardown instead. All of these answers have to be expressed inside an HTTP server object, because that&rsquo;s the thing that owns everything.<br>None of these questions is hard on its own. You stop and ask whether there&rsquo;s a better way because the code gives you nowhere to answer them. The lifetimes you&rsquo;re being asked to coordinate are implicit in five go statements and the order of some struct fields.<br>An Accidental Runtime

Link to heading<br>Every application with more than one long-lived activity has a runtime in the small, whether or not anyone designed it: something decides what those activities are, what they&rsquo;re allowed to touch, how they hear about each other, and when they stop. We normally reserve the word &ldquo;runtime&rdquo; for the machinery underneath the language, but your application has one of its own. In the print-farm service, that setup code lives in NewServer. This wasn&rsquo;t a deliberate choice. The HTTP server was simply the first object that needed the shared state, so it became the place where long-running things get born, and every later addition reinforced the pattern. That&rsquo;s what I mean by an accidental application runtime.<br>Calling this &ldquo;large setup code&rdquo; misses what has changed. The service now has five concurrent components with fairly clear responsibilities and well-defined data flowing between them. You could sketch them on a whiteboard in a minute, once you&rsquo;d worked them out, but no artefact in the codebase...

rsquo code application printers service setup

Related Articles