Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Streaming responses #1600

Closed
ioquatix opened this issue Feb 12, 2020 · 40 comments
Closed

Streaming responses #1600

ioquatix opened this issue Feb 12, 2020 · 40 comments

Comments

@ioquatix
Copy link
Member

@matthewd gave an example of one option by capturing the stream callback.

For reference, here is a fully working example:

require 'trenni'

class Output < Struct.new(:stream)
	def <<(chunk)
		stream.call chunk
	end
end

class DeferredBody < Struct.new(:later)
	def each(&stream)
		later.call(stream)
	end
end

class App
	def call(env)
		buffer = Trenni::Buffer.new(<<~EOF)
		<?r 10.times do; sleep 1 ?>
		Hello World
		<?r end ?>
		EOF
		
		template = Trenni::Template.new(buffer)
		
		[200, {}, DeferredBody.new(->(stream){ template.to_string({}, Output.new(stream)) })]
	end
end

run App.new
% falcon serve --bind http://localhost
% curl --no-buffer --insecure http://localhost:9292 
		Hello World
		Hello World
		Hello World
		Hello World
		Hello World
		Hello World
		Hello World
		Hello World
		Hello World
		Hello World

It also works for HTTP/2

If we can do this already, is there any point to partial rack.hijack?

@boazsegev do you have any opinion about capturing the stream output this way?

@boazsegev
Copy link

@ioquatix ,

I'm not sure what my opinion can add, but I'll give it a shot.

The approach seems straight forward to me. However, I also believe it is inherently different form rack.hijack use cases.

Where this approach represent a TTFB optimization on responses that are server bound but slow (i.e., long database queries), the rack.hijack approach solves different use-cases, such as streaming (i.e., live video) or external event notifications (long polling, SSE).

Does it improve performance?

On a performance note, not all servers and not all clients would oblige the TTBF optimization suggested here.

Not all servers start sending the response before it's complete - the each loop might be summed to test for successful completion or the final response might be tested against the headers (i.e., fixing a missing content-length header).

I know iodine won't send the incomplete response (errors might occur and need to be reported) and this is one of the things I am likely to change for iodine 0.8.0.

Even if the application servers stream the data, browsers might not show all the data until it's complete (or until a large enough buffer was filled).

This is also true for XHR requests where the data might not become available until the whole (or a large enough part) of the data was received.

Side-note:

Another reason iodine doesn't support this model of TTFB optimization is because IMHO this is a design flaw / abuse, which meant I didn't feel the need to support for any of the projects I had at the time.

IMHO, if the response is slow, an API redesign should be considered rather than optimizing the TTFB on slow responses.

My issue with this use of approaches is that they don't offer client responsiveness and errors could be hard to detect. Also, if an error occurs mid-stream, the server might not be able to do anything to signal the error (the client might assume a disconnection meant an EOF rather than an error).

Fragmenting the slow part into faster (yet smaller) request-response cycles is usually better than using streaming for a TTFB optimization, as it allows both more responsiveness and allows better error handling / indication.

@boazsegev
Copy link

boazsegev commented Feb 12, 2020

P.S.,

This was a very interesting use of the template engine internals, allowing the each(&block) to yield within the template's loop, knowing it internally calls the #<< method.

I'm not sure it would work for every template engine, but the same could be done today with any template engine. For example (using iodine's mustache template engine):

require 'iodine'

class SlowDBQuery
   # emulate a slow DB fragmented into a loop of faster chunks
   # the `{number: i}` represents a data structure (i.e., Hash) returned by the DB
  def each
    10.times {|i| yield({number: i}); sleep 1; }
  end
end

class DefferedBody2
  TEMPLATE = Iodine::Mustache.new(template: "<li>Hello World {{ number }}</li>")
  attr_accessor :head, :tail, :enum
  def each(&block)
    yield @head if @head
    @enum.each {|i| yield TEMPLATE.render(i) } if @enum
    yield @tail if @tail
  end
end

APP = Proc.new do |env|
  body = DefferedBody2.new
  body.head = "<html><body><ul>"
  body.enum = SlowDBQuery.new
  body.tail = "</ul></body></html>"
  [200, {}, body]
end

Yes, the code isn't fully optimizes and it is slightly naive, but it does prove that the current each approach can be used even without knowing the internals of a template engine.

The optimizations at this point are mostly a question or application code rather than Rack design.

EDIT:

Works in the terminal using:

APP.call()[2].each {|s| puts s}

Didn't try it within a server.

@ioquatix
Copy link
Member Author

Nice work building and trying it out. You should try it in an actual server, it's fun.

I think what it shows is that #each used in this way is sufficient to replace at least partial hijack.

Full hijack is impossible for HTTP/2. The only use-case I'm familiar with is HTTP/1 WebSockets. Is there any other reason for full hijack?

If there are no good use-cases for rack.hijack I would like to remove it from Falcon.

But, it begs the question, if there are no good use-cases for rack.hijack, can we remove it from Rack 3? Because it's one aspect of rack which completely bypasses the beauty and simplicity of env -> [status, headers, body].

@matthewd
Copy link
Contributor

There's some historical context in the original PR thread, especially starting from #481 (comment), and very especially:

the advantage of partial hijack is just for scheduling and io handoff when people can't understand how to deal with each. It's a cop-out, but I had a lot of smart people simply not getting how to yield each regularly.

I think my mental model would suggest partial hijack if an application wanted to implement e.g. a custom Transfer-Encoding -- along with full hijack for protocol upgrade, and each for anything that's pure-RFC HTTP, regardless of e.g. streaming.

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).

I wonder if anyone actually uses partial hijack. I think I recall that WEBrick supports partial and not full... but I can't remember whether I've come across an application/middleware that uses it.

If there are no good use-cases for rack.hijack I would like to remove it from Falcon.

I think you mean partial hijack here -- is that right? (Unclear because both full and partial use (different) things called rack.hijack.)

@ioquatix
Copy link
Member Author

@matthewd thanks for the links.

I think you mean partial hijack here -- is that right? (Unclear because both full and partial use (different) things called rack.hijack.)

Both. Full hijack is a relic of HTTP/1 which cannot be supported in any logical way with HTTP/2. Partial hijack can already be done using the #each inversion trick you suggested. So, I don't know why it's needed in Falcon, and I love simple.

Welcome feedback on this conclusion - but I feel like if the same logic applies to Rack, wouldn't it be nice to remove rack.hijack from Rack 3? It simplifies the implementation and there is no tangible loss of functionality (that isn't already lost when using HTTP/2).

@ioquatix
Copy link
Member Author

We use full hijack for Action Cable

On this point, why not use partial hijack? you can return the connection headers (upgrade and so on for HTTP/1) and then read from rack.input and write to #each. Then, you could at least be compatible with HTTP/2 WebSockets.

@boazsegev
Copy link

boazsegev commented Feb 12, 2020

@ioquatix :

I think what it shows is that #each used in this way is sufficient to replace at least partial hijack.

Another use case is SSE / long-polling, which won't work with each, since it's an event driven design (possibly subscribing to pub/sub).

If SSE was implemented using each it would lock the servers thread. On evened servers, that would be a death blow.

Full hijack is impossible for HTTP/2. The only use-case I'm familiar with is HTTP/1 WebSockets. Is there any other reason for full hijack?

SSE / long-polling could use either one. If one was to be removed, it could fall back to the other.

In fact, WebSockets could use both as well (a partial hijack to send the upgrade headers). It's really a matter of developer choice.

If there are no good use-cases for rack.hijack I would like to remove it from Falcon.

By god, I wish!

Removing rack.hijack would improve performance by simplifying both the env creation (one less Proc / binding) and the output management (removing the related code paths).

However, this will break existing long polling and web socket implementations.

In fact, I think Discourse uses rack.hijack on Unicorn to avoid long database calls 🙈

@boazsegev
Copy link

boazsegev commented Feb 12, 2020

@matthewd :

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).

It seems to me that all these use-cases collide with concurrency optimizations. They add unexpected threads / fibers to the settings selected by application developers.

For example, running puma -w 4 -t 2 has the unexpected result of more threads than selected, making optimizations harder to manage.

In addition they add network logic to the MVC framework - a framework that could be free of network logic.

I believe each of these could be improved. How can we (as server developers) make this work better?

ActionCable

As server developers we could abstract away the whole network layer, simplifying the Rails code base, making testing easier and ultimately making performance and optimizations easier for application developers.

I believe a WebSocket callback object (as previously suggested) would be the best approach, but the real question is what would the Rails team prefer? What would make things easier for you?

ActionController::Live and streaming views

I believe we could add server side streaming that isn't thread bound (it may break away from the CGI model, but could be made backwards compatible).

As wonderful as each may be, adding a response object with streaming capabilities (perhaps as an env variable rack.response) could help solve this while improving middleware compatibility.

For example, data streamed before the response was returned to the server (by the middleware) could be delayed - allowing headers added my the middleware to be sent before committing the response.

I believe these use cases cause server-side concerns to mingle with the framework code and result in a lot of duplication of work. It would be much better if Rack 3.0 (or a separate specification, if you prefer) could help minimize the duplication of effort and code.

@jeremyevans
Copy link
Contributor

I don't think we should remove full hijacking, as it has use cases in HTTP/1 that are not handled any other way. One example of this is messagebus, which uses full hijacking to take the socket and add it to the pool of sockets it manages. This cannot be done without hijacking of some form. For example, you can use hijack with messagebus and unicorn to serve a large number of long polling clients, even though unicorn itself is single threaded.

The way SPEC is already written, hijacking is not required, and servers do not have to support it. It only specifies how it should work if it is supported. So you can remove hijacking from Falcon and still be completely compliant with the current rack SPEC. We could add a note to SPEC that full hijacking is not compliant with HTTP/2, but that's as far as I think we should go.

@boazsegev
Copy link

@jeremyevans , I would like to offer a counter-opinion.

I believe MessageBus is a prime example of why we should deprecate rack.hijack.

Reading through the MessageBus code shows how a wonderful idea is hampered by the flawed rack.hijack approach - resulting in flawed network logic that could both quietly fail (sending partial data) and is susceptible to attack.

The IO is collected using env['rack.hijack'].call and used directly without testing for errors / partial writes.

This makes the code susceptible to slow clients attacks, buffer saturation concerns and quite failures that will pop up under heavy stress - issues that are never tested against and never reported / logged.

The fact is that keeping rack.hijack is promoting error prone and insecure code that is destined to break if application servers start supporting HTTP/2 (or HTTP/3).

I don't like breaking backwards compatibility, but I believe that we can offer MessageBus a superior and more secure interface.

Breaking backwards compatibility, IMHO, will be better than ignoring the fact that rack.hijack invites error prone and insecure code.

@jeremyevans
Copy link
Contributor

@boazsegev I think if you can identify actual problems with MessageBus, you should probably report them upstream. All software has bugs, and it sounds like the bugs you are describing could happen to a webserver that implements the rack API. Hijacking is basically having the application operate as the webserver for the hijacked connection.

In order to handle MessageBus's usage, it needs the equivalent of hijacking. It needs to be able to signal to the webserver that it will handle the connection and the server doesn't need to deal with it. Otherwise it can't work with existing single-threaded servers such as Unicorn. If you have an idea to replace hijacking while still allowing MessageBus to work with Unicorn, it's definitely something that we can consider.

Hijacking is not inherently more error prone and insecure than the handling of connections that the webserver does. It could be an issue that you don't trust application/middleware developers as much as you trust webserver developers. However, I'm not in favor of removing the capability just because an application/middleware developer might not handle all edge cases properly.

In terms of HTTP/2 and HTTP/3, those connections can just not set rack.hijack, marking hijacking as not available. That's within the rack SPEC. Servers do not have to support hijacking at all and they can still be compliant with the rack SPEC. That doesn't mean hijacking should be removed when using HTTP/1, which is by far the most common case for ruby application servers.

I think we are certainly open to considering interfaces that are superior in all aspects. None such have been proposed yet. All proposed alternative interfaces thus far have had significant tradeoffs.

@boazsegev
Copy link

@jeremyevans , I'm mostly writing just my personal opinion and counter arguments for us to think about both sides. I'm not even sure what I would decide if I had to make the call. It's obviously a difficult decision.

I think we are certainly open to considering interfaces that are superior in all aspects. None such have been proposed yet. All proposed alternative interfaces thus far have had significant tradeoffs.

True.

All I'm proposing is that we discuss a few options and their tradeoffs and see if we can come up with something better than what we've got - something that will offer a solution to all existing use-cases.

I am also in the opinion that we should consider placing rack.hijack deprecation on the roadmap (or at least strongly discourage it's use).

All software has bugs, and it sounds like the bugs you are describing could happen to a webserver that implements the rack API. Hijacking is basically having the application operate as the webserver for the hijacked connection.

Yes, you're totally right. In fact, this is the exact reason why I believe rack.hijack should be deprecated (or, at least, strongly discouraged).

I would never expect a network programmer to make the mistakes I pointed out (such as not testing the value returned from a call to io.write) - however, these are very common mistakes for application developers and beginner networking programmers.

I don't believe we should expect application developers to know anything about writing network code and I believe the specification / interface we offer should abstract away network details and protect users from its pitfalls.

If you can identify actual problems with MessageBus, you should probably report them upstream.

I can, and you're right, but I'm both busy and not in the mood. It's likely that every other gem / app that uses the rack.hijack made the same mistakes in the code.

I'm not about to start an education crusade when I can fix the issue at the source (offer a better API / interface).

In fact, for me, this is what this discussion is all about - solving the issue at the source.

This discussion can do more to solve the issues in MessageBus than a hundred PRs.

Hijacking is not inherently more error prone and insecure than the handling of connections that the webserver does

I beg to differ.

The server code is written by people who spend a considerable amount of time writing network code. Asking the application developers to have the same experience and knowhow is, IMHO, unfair.

In terms of HTTP/2 and HTTP/3, those connections can just not set rack.hijack, marking hijacking as not available.

I think that if I removed rack.hijack support from iodine, people will stop using iodine.

The fact that officially the feature is optional doesn't mean that it isn't required in practice.

I believe that unless we come up with a better (and common) interface, there's no option except somehow supporting rack.hijack.

By offering an updated interface to network events, we could easily make code that's truly transparent to the underlying protocol, improving development experience and ease of use.

@jeremyevans
Copy link
Contributor

All I'm proposing is that we discuss a few options and their tradeoffs and see if we can come up with something better than what we've got - something that will offer a solution to all existing use-cases.

I am also in the opinion that we should consider placing rack.hijack deprecation on the roadmap (or at least strongly discourage it's use).

I agree that we should continue to discuss options and their tradeoffs. I also think we can consider deprecating rack.hijack, assuming we get an option that handles existing use cases for it.

All software has bugs, and it sounds like the bugs you are describing could happen to a webserver that implements the rack API. Hijacking is basically having the application operate as the webserver for the hijacked connection.

Yes, you're totally right. In fact, this is the exact reason why I believe rack.hijack should be deprecated (or, at least, strongly discouraged).

I would never expect a network programmer to make the mistakes I pointed out (such as not testing the value returned from a call to io.write) - however, these are very common mistakes for application developers and beginner networking programmers.

Note that in many cases, IO#write will attempt to retry partial writes, it's not like a pure C call to write(2). IO#write_nonblock is much more similar to write(2) and it's fairly easy to end up with partial writes when using it.

I would expect mistakes for any programmer, myself included. There are probably more of these types of bugs in rack applications using hijack compared to rack webservers, but there are also a lot more rack applications using hijack compared to rack webservers.

I don't believe we should expect application developers to know anything about writing network code and I believe the specification / interface we offer should abstract away network details and protect users from its pitfalls.

To the extent that we can do this in a backwards compatible way for existing hijack users, it seems like a good idea.

If you can identify actual problems with MessageBus, you should probably report them upstream.

I can, and you're right, but I'm both busy and not in the mood. It's likely that every other gem / app that uses the rack.hijack made the same mistakes in the code.

Seems unlikely that every single rack.hijack user is making exactly the same mistakes, but I agree that the problem isn't rare.

I'm not about to start an education crusade when I can fix the issue at the source (offer a better API / interface).

What your proposing to "fix the issue" (removing rack.hijack), would break all existing users of rack.hijack. So instead of working well except in edge cases, you end up with not working at all. I wouldn't personally consider that a fix.

Hijacking is not inherently more error prone and insecure than the handling of connections that the webserver does

I beg to differ.

The server code is written by people who spend a considerable amount of time writing network code. Asking the application developers to have the same experience and knowhow is, IMHO, unfair.

It seems we are in violent agreement then. You say you beg to differ, but then make the same point I made, which is that the problem is exactly the same, your issue is which people are asked to handle it.

In terms of HTTP/2 and HTTP/3, those connections can just not set rack.hijack, marking hijacking as not available.

I think that if I removed rack.hijack support from iodine, people will stop using iodine.

If everyone using iodine is using rack.hijack, it seems like a pretty important feature that we should be very careful in regards to backwards compatibility, doesn't it?

I believe that unless we come up with a better (and common) interface, there's no option except somehow supporting rack.hijack.

I agree that rack.hijack should continue to be supported until and unless we can come up with a better and common interface.

By offering an updated interface to network events, we could easily make code that's truly transparent to the underlying protocol, improving development experience and ease of use.

I think we are open to proposals in this area.

@ioquatix
Copy link
Member Author

@SamSaffron Wondering if you have time to give your input here?

I am interested in looking at MessageBus as a use case for rack.hijack.

I was looking at the implementation and I see 4 (?) distinct paths:

  • Request/response with backlog + log message.
  • rack.hijack steals the socket and puts it into a background worker/threadpool.
  • async.callback with custom thin dependency.
  • Request/response with backlog.

https://github.com/discourse/message_bus/blob/7241f3ecb4c52891f1ee110d5b318f4deb3c402c/lib/message_bus/rack/middleware.rb#L138-L184

This is a similar design to ActionCable - using the rack compatible web server as the front end and some kind of multi-threaded backend which you pass connections into.

Do you think the rack.hijack makes sense and are you happy with the design? Is there an alternative that you think would make sense?

@SamSaffron
Copy link
Contributor

I think rack.hijack works ok, the thin pattern is pretty much abandoned these days as almost nobody uses thin. It did have a minor advantage in that it provided a bit evented IO.

I guess the big bit that is missing imo is some light weight abstraction on top of the sockets you are managing to handle epoll and timers. At the moment we do scheduling using https://github.com/discourse/message_bus/blob/master/lib/message_bus/timer_thread.rb this scales OK, but there is potential that buffers can fill up and scalability go down when you are distributing large events to thousands of clients.

I do agree is can be some what confusing that sometimes we are making payloads by hand including HTTP headers and all that jazz and in other cases using rack shortcuts. Unifying codepaths could be interesting.

I guess my biggest motivator though for change here would be an increase in scalability, if new patterns could reduce latency significantly for cases where we are publishing messages to 10-20k active client it would be interesting. Atm at Discourse we use short polling for anon users to avoid housekeeping costs that long polling entails, but that means anon get ever so slightly less live updates.

@boazsegev
Copy link

@jeremyevans , I believe that we're mostly in agreement.

I agree that we should continue to discuss options and their tradeoffs. I also think we can consider deprecating rack.hijack, assuming we get an option that handles existing use cases for it.

At this point I would suggest we focus on rack.hijack alternatives.

I would also suggest (assuming a viable alternative is found) leaving rack.hijack in place for the next major release (Rack 3.0) with a strong deprecation / discouragement notice (roadmapping to Rack 4.0).

As major players migrate to the new scheme, servers might stop supporting rack.hijack at their discretion. It's an optional feature as it is, there's no real need to remove it from the specification.

I believe that in 3-5 years time all actively maintained gems will migrate to the new alternative (assuming it's viable and better for them than the current rack.hijack approach). At this point we can decide if we can remove rack.hijack from the specifications.

Does that sit well with everyone? (@ioquatix ?)

@boazsegev
Copy link

@SamSaffron

Thank you for your time and for joining the discussion.

the thin pattern is pretty much abandoned these days as almost nobody uses thin. It did have a minor advantage in that it provided a bit evented IO.

Do I understand correctly that you prefer an evened approach? If yes, which callbacks do you require? If not, what would be ideal for you?

the big bit that is missing imo is some light weight abstraction on top of the sockets you are managing to handle epoll and timers

Could you explain more about what you see as epoll handling and timer handling?

Are you looking for simple connection timeout support or something more?

Are you looking for on_close / on_message callbacks? Could the timer handling be satisfied by an on_timeout (or ping) callback for timed out connections?

my biggest motivator though for change here would be an increase in scalability, if new patterns could reduce latency significantly for cases where we are publishing messages to 10-20k active client it would be interesting.

I believe this is possible to a point. This would depend on the network layer, the concurrency model and the pub/sub design. It's not dictated by the interface that Rack will offer.

At this point I could make a sales pitch for my own server or for falcon... but at the end of they day, we're trying to find the best design (not the best code). I believe that once we do, blazing fast solutions will pop up.

@boazsegev
Copy link

@ioquatix I assume that at this point we're looking for a better solution for rack.hijack use-cases and we will keep supporting the #each approach for TTFB optimizations (long responses / response streaming)?

If so, this might bring us back to some of the discussion started in #1272. I still keep some details in the iodine repo, though iodine extends these ideas to support raw TCP/IP as well (as indicated here and sadly undocumented for HTTP requests).

I think you didn't like the idea of setting variables in the env (making env.dup a bug)?

What would you suggest we do different this time so the discussion can produce something actionable?

P.S.

The one thing I would ask is that the specification we end up with doesn't require the Rack gem. I prefer if the servers could follow the specifications without requiring any of the code.

@ioquatix
Copy link
Member Author

The fact is that keeping rack.hijack is promoting error prone and insecure code that is destined to break if application servers start supporting HTTP/2 (or HTTP/3).

I'm strongly in agreement with this.

I think doing anything that bypasses normal request/response processing in Rack should be avoided. "There be the dragons". These issues have already been enumerated.

I feel like we should be limiting users to the concurrency model exposed by Rack and the server itself - e.g. multi-process, multi-thread or multi-fiber is the safe approach, and I think we can support this a bit better by exposing the nature of the server from Rack::Builder.

I wanted to see if this was possible, and it seems like it is.

if defined?(Falcon::Server)
  puts "Using Fiber implementation..."
  class Bus
    def initialize
      @clients = []
    end
    
    def publish(message)
      @clients.each do |queue|
        queue.enqueue(message)
      end
    end
    
    def subscribe
      queue = Async::Queue.new
      @clients << queue
      
      queue.each do |item|
        yield item
      end
    ensure
      @clients.delete(queue)
    end
  end
else
  puts "Using Thread implementation..."
  class Bus
    def initialize
      @clients = []
      @mutex = Thread::Mutex.new
    end
    
    def publish(message)
      @mutex.synchronize do
        @clients.each do |client|
          client.push(message)
        end
      end
    end
    
    def subscribe
      queue = Thread::Queue.new
      @mutex.synchronize do
        @clients << queue
      end
      
      while item = queue.pop
        yield item
      end
    ensure
      @mutex.synchronize do
        @clients.delete(queue)
      end
    end
  end
end

MESSAGES = Bus.new

class DeferredBody
  def each(&block)
    MESSAGES.publish("Hello World from #{Thread.current}!")
    
    MESSAGES.subscribe do |message|
      yield message
    end
  end
end

APP = Proc.new do |env|
  [200, {}, DeferredBody.new]
end

run APP

The only difference in the above is the use of Thread safe primitives, or Fiber aware primitives, which I think is reasonable. The rack interface is not compromised or bypassed.

This ensures that the server is responsible for providing the required level of scalability and you tune it via the same set of parameters.

@ioquatix
Copy link
Member Author

I guess the big bit that is missing imo is some light weight abstraction on top of the sockets you are managing to handle epoll and timers.

I would say this is beyond the scope of Rack.

I do agree is can be some what confusing that sometimes we are making payloads by hand including HTTP headers and all that jazz and in other cases using rack shortcuts.

It also means that such code paths would never be compatible with HTTP/2, which the example I gave above, when running on Falcon, works just fine.

I guess my biggest motivator though for change here would be an increase in scalability, if new patterns could reduce latency significantly for cases where we are publishing messages to 10-20k active client it would be interesting.

I think this is addressed as a server implementation issue. If you use Falcon, I believe you can see improved scalability, and it also seems like iodine could achieve that too.

At this point I would suggest we focus on rack.hijack alternatives.

Given my example above, is an alternative actually required? Because we can do everything via #each and I theory it works on HTTP/1, HTTP/2, and should be supported by all Rack compatible servers.

My current thinking/goal is to try and show we don't need rack.hijack in any form, and that there are better (protocol compatibility, scalability, etc) alternatives. I don't have any vested interest in this, except for making rack as an interface conceptually simpler.

I still believe concurrency models are outside the scope of Rack. It becomes server specific. I believe a gem like message_bus would still need to have concurrency specific implementations of things like message busses, etc. It's impossible for us to specify that all within Rack. That being said, I'm sure there are a few core interfaces that would be very similar. Iodine has an evented model, but fundamentally, it should still make sense within the context of #each. Do you think you can try to make a 3rd implementation for my above example, which works on iodine so we can see what it would look like?

@jeremyevans
Copy link
Contributor

Given my example above, is an alternative actually required? Because we can do everything via #each and I theory it works on HTTP/1, HTTP/2, and should be supported by all Rack compatible servers.

What the provided example doesn't cover is cases where hijack is used to take control of the connection so it is no longer handled by the webserver. @SamSaffron provides an example with MessageBus serving 15-20k active clients. You are not going to be able to do this using streaming responses (body#each) in most common ruby webservers that are single threaded (Unicorn) or thread-per-connection (Puma, Passenger?). Maybe falcon/iodine/agoo can handle cases using fibers or an evented approach, but we should not design rack's API to only work well on servers that are 3 orders of magnitude less popular than the commonly used servers.

My current thinking/goal is to try and show we don't need rack.hijack in any form, and that there are better (protocol compatibility, scalability, etc) alternatives. I don't have any vested interest in this, except for making rack as an interface conceptually simpler.

It's always easy to make things simpler by removing more complex parts and breaking the related use cases. We need to keep supporting the related use cases, and if can be made simpler while doing that, that is something we should consider.

I still believe concurrency models are outside the scope of Rack. It becomes server specific. I believe a gem like message_bus would still need to have concurrency specific implementations of things like message busses, etc. It's impossible for us to specify that all within Rack. That being said, I'm sure there are a few core interfaces that would be very similar. Iodine has an evented model, but fundamentally, it should still make sense within the context of #each. Do you think you can try to make a 3rd implementation for my above example, which works on iodine so we can see what it would look like?

I think Rack's current API doesn't define concurrency models. Hijacking allows you to use a different concurrency model for specific connections compared to normal connections that are not hijacked. While more complex, it enables features that may not otherwise be possible without substantial changes (such as changing webservers).

@boazsegev
Copy link

boazsegev commented Feb 13, 2020

@ioquatix

My current thinking/goal is to try and show we don't need rack.hijack in any form

I think there will always be use-cases that will desirer to degrade (or upgrade) to raw TCP/IP. In the context of HTTP/2, this might degrade (or upgrade) to a tunneled stream.

For example, to avoid rack.hijack within ActionCable than Rack will either require a WebSocket specific extension or a raw stream feature similar to rack.hijack.

I don't have any vested interest in this, except for making rack as an interface conceptually simpler.

No vested interest here either. I don't even care if iodine is surpassed and becomes abandon-ware. I just have a lingering love for Ruby and want to see it flourish.

I still believe concurrency models are outside the scope of Rack. It becomes server specific.

Agreed.

I believe a gem like message_bus would still need to have concurrency specific implementations of things like message busses, etc.

Maybe, but I'm not sure it's an actual requirement.

If the Rack specification abstracts away the network layer, all that's left for message_bus to handle is the pub/sub side of things, which might not require concurrency management at all (except, possibly, a Mutex).

Iodine has an evented model, but fundamentally, it should still make sense within the context of #each.

Honestly, iodine currently doesn't really support #each.

There are 2 reasons for this:

  1. Implementing an evented each would have been a big time consumer (having to pause and renew the HTTP handling event, exiting and re-entering the Ruby GIL). The C code is there, but connecting it to Ruby was a lot of work I didn't want to do.

  2. None of my projects required TTFB optimizations (all responses were limited in scope and clients simply made a lot of requests when filling in large data-sets, such as billing reports etc')... so there was no reason for me to implement each in an evented way.

Currently, #each would block a response thread if waiting for events, which makes it sub-optimal when used with iodine (where I would often prefer using 2 threads per process and made sure to write evented code).

Do you think you can try to make a 3rd implementation for my above example, which works on iodine so we can see what it would look like?

I'm sorry, I can't follow the same design pattern with iodine, since each would block the server.

However, I can use an alternative to rack.hijack with a callback object and I can provide the pub/sub "Bus" using iodine's native Pub/Sub API.

I don't think this is what you wanted, but it would look like this:

require 'iodine'
module MyCallbacks
  GLOBAL_CHANNEL_NAME = :global
  # called when the callback object is linked with a new client
  def on_open client
     # subscribes the client
     private_channel = client.object_id.to_s(16)
     client.subscribe GLOBAL_CHANNEL_NAME
     client.subscribe private_channel
     # global publish
     client.publish GLOBAL_CHANNEL_NAME, "JOINED: client #{client.object_id.to_s(16)}"
     # pushes a Hello World message every 1 second
     Iodine.run_every(1000, 10) { client.publish private_channel, "Hello World" }
  end
  def on_message(client, message)
     client.publish GLOBAL_CHANNEL_NAME, "#{client.object_id.to_s(16)}: #{message}"
  end
  # called when the client is closed (no longer available)
  def on_close client
     client.publish GLOBAL_CHANNEL_NAME, "DISCONNECTED: client #{client.object_id.to_s(16)}"
  end
  # Allows the module to be used as a static callback object (avoiding object allocation)
  extend self
end

RACK_UPGRADE_Q = 'rack.upgrade?'.freeze
RACK_UPGRADE = 'rack.upgrade'.freeze
APP = Proc.new do |env|
    env[RACK_UPGRADE] = MyCallbacks if(env[RACK_UPGRADE_Q])
    [200, {}, ["Please connect using SSE / WebSockets"]]
end

## in config.ru
# run APP
## in terminal
Iodine.listen handler: APP, service: :http
Iodine.threads = Iodine.workers = 2
Iodine.start

## SSE testing from browser:
# var source = new EventSource("/"); source.addEventListener('message', (e) => { console.log(e.data); });

## WebSocket testing from browser (broadcasts a global message):
# ws = new WebSocket("ws://localhost:3000"); ws.onmessage = function(e) {console.log(e.data);}; ws.onclose = function(e) {console.log("closed")}; ws.onopen = function(e) {ws.send("hi");};

@matthewd
Copy link
Contributor

I think my best picture of a future hijack replacement would be that it unify HTTP/1.1 Upgrade and HTTP/2 CONNECT -- so yes, in a more partial-hijack style, providing the initial header response as usual, and then afterward handing off a socket.

H1 hijack already doesn't provide the real raw socket (read: TLS), so an H2 equivalent IO that actually encapsulates its content for an H2 CONNECT tunnel seems consistent... almost to the point that the underlying "raw"-socket-wanting application/framework needn't care whether it's been established via H1 or H2, just as it can currently ignore whether TLS is involved.

@boazsegev
Copy link

@matthewd ,

hijack already doesn't provide the real raw socket (read: TLS)

If I understand you correctly, you don't really expect a Ruby native IO from rack.hijack, but rather an IO abstraction?

This abstraction might not play well with poll / select methods... which makes me wonder (if we're going to wrap the IO anyway - why not ask for more (and avoid these compatibility issues)?

IMHO, I think we could ask for more from the future. So much potential.

providing the initial header response as usual, and then afterward handing off a socket.

Wouldn't you prefer that the server continued to "poll" the IO for incoming data (so you didn't have to)?

Wouldn't it be nice if you got notified if the connection was lost / closed instead of polling the IO state?

What about timeouts? Wouldn't it be nice it you didn't have to manage timeout logic...?

IMHO, using a callback object would allow you to simplify your code and focus on what you want done instead of forcing you to maintain a whole network "backend" that simply duplicates what the server already implemented...

Side note:

H1 hijack already doesn't provide the real raw socket (read: TLS),

You're assuming...

I think that if you tried that with iodine (using it's TLS support), you'd find that you got the raw IO and needed to initiate a new TLS session.

Though that may be considered a bug 😅

@ioquatix
Copy link
Member Author

What the provided example doesn't cover is cases where hijack is used to take control of the connection so it is no longer handled by the webserver. @SamSaffron provides an example with MessageBus serving 15-20k active clients.

It would be nice if it was as simple as shifting to a more scalable web server though. But because of the way we force the implementation, it's not possible. rack.hijack as it is currently used is basically a crutch for the limited scalability of existing servers, using process per connection and thread per connection. Newer servers with better concurrency models can't solve this problem when rack.hijack is used, which is disappointing.

There are also valid reasons for using puma with thread per connection given the current eco-system of gems which expect to run per-thread, e.g. ActiveRecord. I've also had people tell me they prefer Unicorn because there should be in theory no chance of thread safety issues. So, I don't actually see this as a disadvantage, so much as exposing the concurrency model of the server. For many users, process per connection could be acceptable, even for long running connections (at the very least it's great from an isolation POV if you were, for example, dealing with sensitive data).

Thread per connection in Ruby 2.7 also should be fairly scalable, e.g. I wouldn't recommend it but you should be able to run at least thousands of threads with Puma.. in 2.7 I reduced memory usage and per-thread allocation costs. Beyond that you probably want an event driven model which everything going well will be a solid part of Ruby 3.

So, yes, right now with the situation we are in, rack.hijack is kind of required, because of the lack of scalability. But it's never going to work with HTTP/2 in it's current form. So, I don't think we should be encouraging it's use in Rack 3 and the approaches we recommend here seem good to me.

I think my best picture of a future hijack replacement would be that it unify HTTP/1.1 Upgrade and HTTP/2 CONNECT -- so yes, in a more partial-hijack style, providing the initial header response as usual, and then afterward handing off a socket.

That's already what falcon does for a partial hijack. Here is how the server side of async-websocket works:

https://github.com/socketry/async-websocket/blob/d3af4b1842c000baad2e98a419ef72cd0919ce24/lib/async/websocket/response.rb#L31-L37

There is no equivalent for full hijack for HTTP/2 without changing the definition for full hijack.

H1 hijack already doesn't provide the real raw socket (read: TLS), so an H2 equivalent IO that actually encapsulates its content for an H2 CONNECT tunnel seems consistent...

Right, so:

  • HTTP/1 with no TLS: Raw I/O, thread safe (ish), can be used with IO.select, can even be sent between processes.
  • HTTP/1 with TLS: TLS I/O (OpenSSL::SSLSoclet), thread safe (ish), can be used with IO.select, more tricky to send between processes.
  • HTTP/2 stream (with or without TLS). Stream wrapper. Not thread safe. Can't be used with IO.select.

So, I think that full rack.hijack gets very tricky to support with HTTP/2 in the same way that rack.hijack is used when going via an HTTP/1 connection.

@ioquatix
Copy link
Member Author

ioquatix commented Feb 13, 2020

By the way, here is the rack handler for async-websocket. One part that's missing is some kind of official support in rack for Upgrade: $protocol (HTTP/1) or :protocol: $protocol (HTTP/2, defined in RFC8441), so it's pulled directly from the async.http.request object.

This response.body essentially responds to #call which invokes this code which receives the stream object. This object looks like an I/O and in HTTP/1 is the upgraded connection or in HTTP/2 is the CONNECT tunnel.

To me, this approach is superior to partial rack.hijack because it's simpler. But, now I'm also interested to try the #each method to see if I can remove the requirement for partial hijack.

Full hijack, as outlined above, is not supported for HTTP/2, and works as expected for HTTP/1 in Falcon. I added it so that ActionCable would work, and it seemed like fun at the time. So, I don't recommend it because if you run your server in HTTPS mode (the default for Falcon) you will not have support for full hijack.

@ioquatix
Copy link
Member Author

ioquatix commented Feb 13, 2020

Oh, I guess I should add, there IS one way to make HTTP/2 behave like a full hijack socket, and I also implemented it... but not for full hijack - but for proxying via HTTP/2 CONNECT.

It turns out OpenSSL needs an actual socket. So if you want to proxy HTTP/2 or TLS connections over an existing HTTP/1 or HTTP/2 connection, you need to expose a socket.

For HTTP/2, we make a stream with a CONNECT $remote-host, then we create a socket pair. We bind one end to the HTTP/2 stream, and provide the other end to the OpenSSL SSLSocket wrapper. Surprisingly this works pretty well. So, if you are willing to live with an intermediary socket and gluing the ends together, in theory you could provide raw I/O attached to an HTTP/2 stream. But yeah, uh, not sure I'd recommend that approach. It's definitely not light weight.

@ioquatix
Copy link
Member Author

ioquatix commented Feb 13, 2020

Wouldn't it be nice if you got notified if the connection was lost / closed instead of polling the IO state? What about timeouts? Wouldn't it be nice it you didn't have to manage timeout logic...?

I do get your approach, I really do. But I don't feel we can expose these interfaces without also implicitly including a specification for concurrency, which I think we all agree it outside the scope of Rack.

Rack is really great because it works completely synchronously. There is no asynchronous behaviour in Rack itself. To me, this is really a great part of rack, maybe the best part. It's just a function of env->[status, headers, body] with the life cycle of body being clearly defined as yielding chunks sequentially. That being said, I do believe if someone was to make a message bus and target iodine, in that context, the event mechanism does make sense.

If #each is blocking, does that cause Iodine to completely stall?

@matthewd
Copy link
Contributor

HTTP/2 stream (with or without TLS). Stream wrapper. Not thread safe. Can't be used with IO.select.

Is the "Not thread safe" a consequence of the "Can't be used with IO.select", or is there some other issue I'm not immediately seeing?

Seems like the IO.select part could possibly be solved upstream, in the long term. Given Ruby's open-ended duck-typing-preferred nature, and how important IO.select is to the use of IO objects, it seems unfortunate that an artificial IO object can't participate. (Though that'd only move the needle slightly for this particular case, because in practice Action Cable uses nio4r, not actual-IO.select.)

@ioquatix
Copy link
Member Author

ioquatix commented Feb 13, 2020

Is the "Not thread safe" a consequence of the "Can't be used with IO.select", or is there some other issue I'm not immediately seeing?

Here is a high level overview of what HTTP/2 looks like.

image

All reads and writes must go via the main connection/framer. At least in async-http this object does not provide thread safety as it's simply not necessary.

For an individual stream, there is no object to provide to IO.select. If you tried to wait on the IO of the server, there is no guarantee that the data it would read would be relevant to your specific stream. If you thought using the socket pipe proposal above would make sense, here is what it would look like:

image

@ioquatix
Copy link
Member Author

Bearing in mind that if someone tried to do it, and writes HTTP/1 formatted data, they will probably have unexpected results :)

@boazsegev
Copy link

@ioquatix , to answer your questions (and add some of my own),

If #each is blocking, does that cause Iodine to completely stall?

Yes.

Pretty much like any single threaded code flow, if you block the main thread - it stalls.

If you call sleep or wait_for_foo from within the each loop, iodine can't reclaim control of the thread and all the evented model breaks.

You can set up iodine to use multiple threads, but that just means that you will have multiple chances to block each before the server stalls.

FYI: this is also true for node.js and your web browser, where they try solving it by preventing the JS API from allowing the developer to block... but add a while(1) loop and watch it all fall apart.

I don't feel we can expose these interfaces without also implicitly including a specification for concurrency, which I think we all agree it outside the scope of Rack.

  1. I don't understand why the interface requires a specific specification for concurrency.

    Could you explain?

  2. If some limitations on concurrency are required (such as, "on_message MUST NOT be called concurrently for the same connection"), than I'm not sure it's out of scope for Rack - even though I agree Rack shouldn't require a specific concurrency model.

Rack is really great because it works completely synchronously...

Yes, but this is part of the reason rack.hijack exists and part of the reason #each can't replace it.

Some use cases require a more complex control-flow. They want to write and read and test for timeouts and wait for DB updates - in other words - they want to react to external events.

WebSockets and long-polling are simple use-cases that fit this description, but they're not the only ones. There's server-2-server communication, video streaming, gossip protocols...

All these could benefit from a better rack.hijack alternative.

My current thinking/goal is to try and show we don't need rack.hijack in any form,

I understand your desire for simplicity - I love simple. But at the end of the day, no one will give up the rack.hijack interface if we don't offer them an alternative that gives them a similar level of control / flexibility.

If users don't get the same features (or better solutions), they will keep using rack.hijack.

You might be able to emulate rack.hijack on falcon using #each, but this is more related to your specific fiber based concurrency model than to the specification.

I don't think it would be fair for Rack 3.0 to require all servers to switch to a fiber-per-connection model.

@boazsegev
Copy link

@matthewd ,

Seems like the IO.select part could possibly be solved upstream ... Though that'd only move the needle slightly for this particular case, because in practice Action Cable uses nio4r, not actual-IO.select

As I think you know, IO.select is broken.

It's limited to fd with a value less than 1024 (on glibc and some OSs) and I think the situation is worst on windows.

This is why the move to nio4r was so important for hijacked connections - connections that could easily reach values that exceed the 1024 limit because of their extended lifespan.

... actually, this may be considered a design requirement that would minimize code errors (preventing users from using IO.select).


@matthewd, @ioquatix , @jeremyevans , @SamSaffron - quick question:

Assuming that:

  1. hijacked connections currently require IO polling (they actually want to the socket, not HTTP streaming, which they can do with each);

  2. we want users to avoid IO.select (as it's broken on some systems and difficult to support, which is why ActionCable switched to nio4r); and

  3. we want to provide a better rack.hijack alternative (not simply remove it);

wouldn't it be better if polling was integrated into the alternative solution so users didn't have to do it?

Also, can anyone see use cases that require hijacking (can't be performed with each) but won't require polling?

@ioquatix
Copy link
Member Author

ioquatix commented Feb 13, 2020

I don't understand why the interface requires a specific specification for concurrency.

Well, my understanding of the callbacks you've proposed, require non-linear flow control.

Assuming that's correct, then it requires the definition of a model for concurrency, e.g. "event driven callbacks".

It's not a hard line I guess, but if you implement a single connection per process web-server, can you implement your event driven model in a purely sequential way?

wouldn't it be better if polling was integrated into the alternative solution so users didn't have to do it?

To me, this is a model of concurrency, which I don't think we want to introduce. There is value in servers which are process-based (unicorn), thread-based (puma) or fiber-based (falcon). How would polling add value to a process-based server? How would it work in a fibre-based server that already has a more elaborate model for concurrency? It introduces a model which will create an impedance mismatch with existing models of parallelism & concurrency.

@boazsegev
Copy link

boazsegev commented Feb 13, 2020

Well, my understanding of the callbacks you've proposed, require non-linear flow control.
...
if you implement a single connection per process web-server, can you implement your event driven model in a purely sequential way?

Sure I can.

A thread/fiber-per-connection design would work better though, since on a per-process web server you won't be able to share the client object with other connections (making message_bus impossible).

In a sequential (non-event based) implementation, the client "thread" is in a read loop:

# pseudo code
client.handler.on_open(client)
while(!client.closed?)
   data = client.read_with_timeout()
   if(data)
      client.handler.on_message(client, data)
   else
      client.write_locked? || client.closed? || client.handler.on_timeout(client) || client.close
   end
end
client.handler.on_close(client)

The client write will need to block until finished and then (optionally) call the on_drained callback.

The client write will also need to be protected because it may be accessed from within another thread/fiber (since the client object might be shared with others):

# pseudo code
class Client
   def write(data)
      client.write_lock
      start = 0
      while(start < data.length && !client.closed?)
         written = client.io.write(data[start..-1])
         (written.to_i > 0) ? (start += written.to_i) : client.close() # assuming IO is blocking
      end
      client.write_unlock
      client.handler.on_drained(client) # if implemented, I wouldn't implement this with a sequential design
   end
end

All in all, there aren't many critical paths and they could probably be avoided by making some callbacks optional (such as requiring on_drained only for servers that implement an internal buffer).

Assuming that's correct, then it requires the definition of a model for concurrency, e.g. "event driven callbacks".

I don't think it's correct. A non-evented server could give the illusion of calling these callbacks in an evened way, but it should be fairly easy to implement them as sequential code.

@julik
Copy link
Contributor

julik commented Nov 29, 2020

I hope this input can provide useful: we do use the partial hijack, and I disagree that

This makes the code susceptible to slow clients attacks, buffer saturation concerns
and quite failures that will pop up under heavy stress - issues that are never tested against and never reported / logged.
The fact is that keeping rack.hijack is promoting error prone and insecure code that is destined to break if application servers start supporting HTTP/2 (or HTTP/3).

From the user perspective I think this is somewhat misguided, because when I do use hijack and I know I am only operating in HTTP 1.1 context I do so deliberately, and thus handling slow clients is my problem (which I indicate to the webserver by opting for the facility in the first place). I believe the wording is also poorly chosen because you are implying that if I am using the facility I am doing so while being clueless to the possible side effects and "quiet failures". In our case they are not quiet because code that runs within the hijack is subject to APM error capturing just like any other code. Instead of letting sockets be handled through IO.select we also opted for nio4r which we run in a separate thread - when a write will block, we pause the thread and ship it to a central reactor to be resumed once writes are again possible.

Yes, it is not HTTP/2-compatible because it does not observe the framing guarantees (I would rather leave my opinions on HTTP/2 out of the conversation) - but with HTTP1 it allows us to service multiple hundreds of very heavy downloads per box, without having the string churn (and overload of separate IO.select loops) which would be the case with Rack#each.

@ioquatix
Copy link
Member Author

I think the ideal solution is that you can write your code, and if you write it in a generic way, you get the scalability of the server you choose. Farming sockets off to a custom reactor in a separate thread seems like a lot of work.

@julik
Copy link
Contributor

julik commented Nov 30, 2020

And it is 😭 but the other solution with Puma would be to persuade them to use nio4r for response writes - as at the moment it is only used for reading the request, writing the response still uses IO.select per thread. When there is a ton of these running CPU gets quite hot. High hopes on that alternative server though, TBC 😉

@boazsegev
Copy link

@julik ,

I believe the wording is also poorly chosen because you are implying that if I am using the facility I am doing so while being clueless to the possible side effects and "quiet failures".

I apologize if my language seemed offensive. I didn't mean to imply that web application developers are clueless about network concerns.

I think that any unsafe code is the result of a design failure on our part, since app developers should have been free to assume that the IO would just work. They should have been free to assume that the server will take care of all the gritty details of any network concerns.

High hopes on that alternative server though...

I assume you'll be trying out falcon, iodine or both.

I think falcon's green threads might solve the CPU pressure.

On my part, I know that iodine polls for write availability using the same epoll/kqueue system call. Though, depending on the version you're running, I think write events get prioritized over read events. Also, responses are copied to a C buffer before scheduling a write, which might or might not increase memory usage.

@ioquatix
Copy link
Member Author

This is now resolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants