Origin and utility of ActionController::Live threads

I’ve been using Rails (and Rack) for streaming for a long time - in different applications. Oddly enough I have always had difficulties with ActionController::Live, primarily because of its off-threading nature. Just to name a few things:

  • With RSpec, for quite a long time Live responses would not work
  • With apartment, the current tenant context would be lost when moving the response streaming workload off-thread
  • Multiple odd things with ActiveSupport::Current, don’t remember the exact details
  • Impact on ActiveRecord connection management. With apartment and one database per tenant, for example, the connection would be reverting back to the “main” tenant DB when the first ActiveRecord database query would need to be done
  • Impact on server configuration. Those Live response serving threads - do they count up to the Puma worker count and Puma thread count? How would they work with Falcon if it were used?
  • In test with minitest and default test helpers, it does not off-thread - which creates a radically different behavior compared to production.

What I found works for me is wrapping my responses in a streaming Rack body and assigning it to the ActionDispatch response - but I see multiple people asking about ActionController::Live as to “why is it so different”.

I was wondering why it was built in the first place - @tenderlove was it because GH needed something for Unicorn, or because most of ActionController at the day insisted on buffering?

My question is - if Live is a good API (and it seems so, except for the dances with committed headers and all) - why does it need to do the off-threading? Maybe it is a feature that is no longer justified, and it can just continue using the webserver thread it already sits in?

There’s a lot of context in this issue: GitHub · Where software is built

I think the short answer is: a separate thread was previously necessary to return headers before processing the body:

Reading the code, you can see that Thread locals get copied over into the new thread. Which I’ve seen break instrumentation, but is a workaround.

I’ve seen it break more things. The comment that you are referring to is useful, but it does not explain the reason to why it is done with an extra thread, if that makes sense. I was also in that GH thread you are referring to - but Matthew says:

That said, Rails doesn’t use partial hijacking, nor can I find any evidence it ever has. We use full hijack for Action Cable, each + a fiber for streaming views, and each + a thread for ActionController::Live (for API design reasons).

What the “API design reasons” are is what I am trying to figure out. Because I don’t see why this extra thread couldn’t just be removed from ActionController::Live - it would not change the API for the end-user at all.

The thread could now potentially be replaced with a fiber, if that’s what you mean, though I think that would have similar effects to your original list… and at least need separate careful consideration between the requirements of thread- and fiber-based servers.

If you see a way to avoid even that, without changing the user-facing API, I’d love to see a PoC PR: it seems impossible to me.

Challenge accepted :wink: I think my original list would be stricken almost completely, provided one important thing: the middleware Rails adds should “reset” not on the return from the controller action, but on the close of the returned Rack body. But (IMO) this should have always been this way.