The Accidental Application Runtime

MichaelFBryan1 pts1 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 and jobs started arriving from upstream. The service grew a goroutine that pulled them into a SQLite-backed job store. Once they were there, it also needed to match queued jobs to idle printers, send G-code, and watch the print. Those pieces all landed in the same setup path because that&rsquo;s where the printer connections and store already lived. Each change was small enough to do in 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

There isn&rsquo;t an obviously bad line here. Each one was a reasonable next step, and the application does print things. It&rsquo;s only when you look at the whole function that the problem becomes apparent: the setup code 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 especially hard on its own. This is usually where I stop treating it as a shutdown problem, because the code gives me nowhere sensible to put the answers. The lifetimes are implicit in five go statements and the order of some struct fields.<br>An Accidental Runtime

Link to heading<br>I find it useful to think of any application with several long-lived activities as having a small runtime of its own. Something still decides what those activities are, what they&rsquo;re allowed to touch, how they hear about each other, and when they stop, even if nobody designed that part explicitly. In the print-farm service, the runtime happens to live in NewServer. The HTTP server was simply the first object that needed the shared state, so it became the place where long-running things get born. Later additions followed the existing 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 expresses that design. It exists only in the side effects of wiring code: a field here, a go statement there, a channel...

rsquo code printers service application runtime

Related Articles