DEV Community

Isa Levine
Isa Levine

Posted on

HELP NEEDED: Understanding Rails ActionController::Live Module (and Async Limitations)

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:

  1. A server that broadcasts hashes containing randomly-generated characters (ideally repeating every 1 second, indefinitely)
  2. 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...

gif of hashes being rendered in Chrome browser one by one, with a 1 second delay
working fine in Chrome...

gif of hashes being rendered in console one by one, with a 1 second delay
working fine in console...

...but in the Receiver server's console, all the character-hashes come at once in the HTTP response!

gif of hashes being returned as one http res.body response in Rails console
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:

gif of hashes being rendered in console one by one, with a 1 second delay

Since navigating to http://localhost:3000/broadcaster in the browser will also send a Get request, we see the same behavior here in Chrome:

gif of hashes being rendered in Chrome browser one by one, with a 1 second delay

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:

gif of hashes being returned as one http res.body response in Rails console

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?

Any help, advice, or insight is greatly appreciated! <3

Links/Tutorials used:

Top comments (11)

Collapse
 
derrelldurrett profile image
Derrell Durrett

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

Collapse
 
isalevine profile image
Isa Levine

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! :)

Collapse
 
derrelldurrett profile image
Derrell Durrett

Many thanks!

Collapse
 
elyalvarado profile image
Ely Alvarado

It looks like the issue is not in your broadcaster server, but in your listener server.

From the Net::HTTP docs:

By default Net::HTTP reads an entire response into memory

So you need to use response.read_body and pass a block to it, instead of response.body in your listener:

See the docs: ruby-doc.org/stdlib-2.6.5/libdoc/n...

Collapse
 
isalevine profile image
Isa Levine

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:

require 'net/http'

class ListenerController < ApplicationController
    def index
        url = URI.parse('http://localhost:3000/broadcaster')

        Net::HTTP.start(url.host, url.port) do |http|
            request = Net::HTTP::Get.new(url.to_s)
            http.request(request) do |response|

                puts <<-READOUT

                res.read_body:
                ==============================

                READOUT

                response.read_body do |data_str|
                    if 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
        end
    end
end

Thank you again!

Collapse
 
nikolalsvk profile image
Nikola Đuza

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!

Collapse
 
256hz profile image
Abe Dolinger

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?

Collapse
 
isalevine profile image
Isa Levine

That's what I thought too! One of the things I tried was moving the 5.times do loop outside of the begin statement, so that a new SSE is opened and closed once per loop:

        5.times do  
            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

And fascinatingly, only ONE character is generated and sent now! Whyyyyy Rails, why won't you let me send multiple SSEs?!

Collapse
 
256hz profile image
Abe Dolinger • Edited

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?

Collapse
 
fuentesjr profile image
Salvador Fuentes Jr

I'm having trouble with this as well ... what version of rails are you using?

Collapse
 
isalevine profile image
Isa Levine

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