The Situation: Practicing Data Ingestion in Rails (and Trying to be Async)
So, for reasons that (ahem) may or may not be job-interview-related, I'm practicing building a Rails server for ingesting, parsing, and storing data in a database.
My goal was to make two Rails apps, and have them talk to each other:
- A server that broadcasts hashes containing randomly-generated characters (ideally repeating every 1 second, indefinitely)
- A server that listens for and receives the hashes, and parses them as they're received to store in a database
I intended to implement this behavior asynchronously with the ActionController::Live module. The intended behavior is for the Broadcast server to emit a hash (as a string) every 1 second, and for the Receiver server to parse and store each hash as they come in. (For my tests, I have this looping 5 times.)
My problem is that the character-hashes are rendered 1-by-1 when testing in my browser and in the Broadcast server's console...
...but in the Receiver server's console, all the character-hashes come at once in the HTTP response!
all the JSON is received as one response body!
So why is the async behavior (apparently) working in some places, and not in others?
Broadcaster server
The Broadcaster server is a simple Rails app that uses a BroadcasterController
with ActionController::Live
and its server-side event (SSE
) module.
The index
method generates a random character_hash
, writes it to the current SSE response.stream
in the variable sse
, and pauses for 1 second to illustrate async behavior.
# Broadcaster app
# /app/controllers/broadcaster_controller.rb
class BroadcasterController < ApplicationController
include ActionController::Live
def index
name_array = ["Ryu", "Peco", "Rei", "Momo", "Garr", "Nina"]
hp_array = [132, 71, 15, 1, 0, 325]
magic_array = ["Frost", "Typhoon", "Magic Ball", "Ascension", "Rejuvinate", "Weretiger"]
response.headers['Content-Type'] = "text/event-stream"
sse = SSE.new(response.stream)
begin
5.times do
character_hash = {
"uuid": SecureRandom.uuid,
"name": name_array.sample,
"hp": hp_array.sample,
"magic": magic_array.sample
}
sse.write({ character: character_hash })
sleep 1
end
rescue IOError
# client disconnected
ensure
sse.close
end
end
end
# Broadcaster app
# /config/routes.rb
Rails.application.routes.draw do
get 'broadcaster' => 'broadcaster#index'
end
Once we start the server with rails s
, we can use curl -i http://localhost:3000/broadcaster
in the command line to send a Get request to the index
method. The response will return each character with a 1 second delay in-between:
Since navigating to http://localhost:3000/broadcaster
in the browser will also send a Get request, we see the same behavior here in Chrome:
So far, so good...
Receiver server
The other Rails app is a Receiver server that sends a Get request to the Broadcaster at http://localhost:3000/broadcaster
, and parses its response to store the received characters in the database.
We also have it puts
a readout to show us the res.body
characters arriving all at once, instead of asynchronously as we saw above.
# Receiver app
# /app/controllers/listener_controller.rb
require 'net/http'
class ListenerController < ApplicationController
def index
url = URI.parse('http://localhost:3000/broadcaster')
req = Net::HTTP::Get.new(url.to_s)
res = Net::HTTP.start(url.host, url.port) { |http| http.request(req) }
puts <<-READOUT
res.body:
==============================
#{res.body}
READOUT
char_array = res.body.split("\n\n")
char_array.each do |data_str|
data_hash = eval(data_str.slice!(6..-1)) # slice to remove leading "data: " substring
char_hash = data_hash[:character]
Character.create("uuid": char_hash[:uuid], "name": char_hash[:name], "hp": char_hash[:hp], "magic": char_hash[:magic])
end
end
end
# Receiver app
# /config/routes.rb
Rails.application.routes.draw do
get 'listener' => 'listener#index'
end
Thus, when we start the server with rails server -p 3001
and send a Get request with curl -i http://localhost:3001/listener
, we call the ListenerController's index
method.
Here, index
sends a Get request to our Broadcaster server at localhost:3000/broadcaster
. But instead of seeing the asynchronous behavior we saw before, it all arrives at once:
So, instead of parsing each character as they come in as separate objects, we have to split the res.body
into separate strings. And of course, we have to wait until all 5 characters are finished generating before we receive them. So much for scaling it up to send an unlimited number of characters!
Where I'm At
From the research I've done, I think the async behavior is being limited by Rails' use of the standard HTTP request/response cycle as the basis for ActionController::Live
. As such, each request only gets one response, and that's why all the characters have to come back as one res.body
string!
Per this excellent article by Eric Bidelman covering SSEs in HTML5, I thought I was moving toward implementing long polling...but apparently not.
Further, the tutorials I'm following usually expect us to build a JavaScript event listener to catch the async data from the Broadcaster server. So, is it just browser-magic that's making the ActionController::Live async behavior work in Chrome?
But then, why do the characters still appear to be coming in asynchronously when we use curl
directly on localhost:3000/broadcaster
...
...and NOT when using curl
indirectly through localhost:3001/listener
?
Top comments (11)
If by chance you still have this project's code available, could you share the configurations you used? I'm trying to use server-sent events, and I can't even duplicate the behavior you demonstrate here (I'm interested in creating an API that pumps out events while waiting for something else to finish, and am only attempting to see the events via curl, to no avail).
Hi Derrell! Here are the repos for the broadcaster and listener, please feel to poke around for the configurations you need:
Broadcaster: github.com/isalevine/rails-data-in...
Listener: github.com/isalevine/rails-data-in...
Let me know if you run into specific issues setting these up and running them, and I'll help as best I can! :)
Many thanks!
It looks like the issue is not in your broadcaster server, but in your listener server.
From the Net::HTTP docs:
So you need to use
response.read_body
and pass a block to it, instead ofresponse.body
in your listener:See the docs: ruby-doc.org/stdlib-2.6.5/libdoc/n...
Hi Ely, great call on this!
response.read_body
is exactly what I was missing. I really appreciate you pointing me to the right place in the docs! :)In the end, here's the listener_controller code I used:
Thank you again!
Hi, Isa. I ran into similar problems like you did. Here's a blog post that explained it well jvns.ca/blog/2021/01/12/day-36--se...
I had to enforce
Last-Modified
header to get SSE to actually stream instead of showing up at once. Hope this helps. Thanks for an awesome article!Maybe it's waiting for the end of the stream? What happens if you open and close the stream each time you generate a character?
That's what I thought too! One of the things I tried was moving the
5.times do
loop outside of thebegin
statement, so that a new SSE is opened and closed once per loop:And fascinatingly, only ONE character is generated and sent now! Whyyyyy Rails, why won't you let me send multiple SSEs?!
That's interesting. Well, at least it confirms it's waiting for the end of the stream to finish receiving. I wonder if the receiver can be somehow switched back into a state of listening for a new connection at that point?
I'm having trouble with this as well ... what version of rails are you using?
Hi Salvador! Both the broadcaster and listener repos (linked below) are using Rails 5.2.3.
Broadcaster: github.com/isalevine/rails-data-in...
Listener: github.com/isalevine/rails-data-in...