Skip to content

Refactor ActionCable streaming#27044

Closed
palkan wants to merge 2 commits intorails:masterfrom
palkan:ac/refactor-streaming
Closed

Refactor ActionCable streaming#27044
palkan wants to merge 2 commits intorails:masterfrom
palkan:ac/refactor-streaming

Conversation

@palkan
Copy link
Contributor

@palkan palkan commented Nov 14, 2016

Implementation of #26999 proposals.

What have been done:

  • streaming handlers logic have been moved from streams.rb into separate classes
  • avoid JSON double-encoding

The benchmark show the following:

Calculating -------------------------------------
                     PR 268.290  (±23.9%) i/s -      1.200k in   5.036351s
             current     75.673  (±15.9%) i/s -    365.000  in   5.034415s

So, broadcasting is about 5-6x faster.

/cc @maclover7 @matthewd

Copy link
Contributor

@maclover7 maclover7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking very good so far 👍

Two small side notes:

  • Can you add a description of the changes (especially the performance improvements) to Action Cable's CHANGELOG.md?
  • Can you take a look at the failing Code Climate build?

module ActionCable
module Channel
module StreamsHandlers
class Base # :nodoc:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move Base's :nodoc: declaration up to StreamHandlers? Same deal with Custom.

module Channel
module StreamsHandlers
class Base # :nodoc:
attr_reader :channel, :identifier
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be missing something, but is there are any reason why channel and identifier need to be part of the public API? 😬


def identity_handler
-> message { message }
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactoring these stream handler factory methods means they're no longer available for overriding in subclasses.

ActionCable::Channel::StreamsHandlers::Custom.new(self, handler: user_handler, coder: coder)
else
default_stream_handler broadcasting, coder: coder
ActionCable::Channel::StreamsHandlers::Base.new(self)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should check that coder is JSON, too.

module ActionCable
module Channel
module StreamsHandlers
class Base # :nodoc:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a specialized no-op stream transmitter for use when there's no user handler and pubsub & transmit coders match. Wouldn't call it a Base transmitter which suggests it's meant for public API and subclassing.


alias encode decode
end
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a no-op coder, hence the existing "identity" language. Introducing "null" language ambiguously suggests that it's coding as null/nil or doing null coding.

def call(message)
handler.(coder.decode(message))
end
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't inheriting anything substantial from Base—the inheritance relationship is misleading.

websocket.transmit encode(cable_message)
def transmit(cable_message, encoded = false) # :nodoc:
websocket.transmit(encoded ? cable_message : encode(cable_message))
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Boolean flags in method signature are a good red flag to investigate.

In this case, we really want a separate API to transmit already-encoded messages. The existing #transmit can invoke that API after encoding its message.

when "custom"
DummyEncoder
when "none"
nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This boilerplate isn't particular to this test; wouldn't move it.

def call(message)
# We can skip message decoding and generate payload in a more simple way
connection.transmit(
"{\"identifier\":#{identifier.to_json},\"message\":#{message}}",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should identifier be quoted here? It's an embedded JSON document.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we should call to_json (which adds surrounding quotes and escapes existing quotes), 'cause identifier is a JSON string itself.

-> (message) do
transmit handler.(message), via: via
end
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could introduce coder-aware stream handling directly:

def stream_handler(broadcasting, user_handler, coder: nil)
  if user_handler
    user_stream_handler broadcasting, user_handler, coder: coder
  else
    default_stream_handler broadcasting, coder: coder
  end
end

def user_stream_handler(user_handler, coder: nil)
  -> encoded_message { user_handler.(coder.decode(encoded_message)) }
end

def default_stream_handler(broadcasting, coder: nil)
  coder ||= ActiveSupport::JSON
  via = "streamed from #{broadcasting}"

  if coder == ActiveSupport::JSON
    -> encoded_message { transmit_encoded encoded_message, coder: coder, via: via }
  else
    -> encoded_message { transmit coder.decode(encoded_message), via: via }
  end
end

@matthewd
Copy link
Member

I haven't yet looked in detail, but even in the no-op case, I wonder if we should decode to ensure it's valid JSON before we throw it at clients

@palkan
Copy link
Contributor Author

palkan commented Nov 15, 2016

I haven't yet looked in detail, but even in the no-op case, I wonder if we should decode to ensure it's valid JSON before we throw it at clients

No-op case doesn't mean that the message is JSON-encoded. For example:

ActionCable.server.broadcast "test", { x: 1 }, coder: MyYAMLCoder

No-op only requires that message is a string. Nothing more. Kind of stupid re-transmission.

Add Connection#transmit_encoded and Channel#transmit_encoded.
Avoiding unnecessary decoding and logging.
@palkan palkan force-pushed the ac/refactor-streaming branch from 50879f6 to 4eae47a Compare November 15, 2016 11:48
@palkan
Copy link
Contributor Author

palkan commented Nov 15, 2016

@jeremy
Return steam handlers generation back to streams.rb. Looks more clear and concise, thanks!

Also, #transmit_encoded methods added to both Connection and Channel.
I intentionally skip logging and instrumentation. First, due to the overhead (adding logging decrease performance about twice!); secondly, I don't think that logging re-transmissions make any sense (we're logging broadcasting as a whole).

@maclover7
Changelog entry added.
Code Climate is green.

@Preen
Copy link

Preen commented Mar 8, 2017

Is there any news on this?

@rails-bot
Copy link

rails-bot bot commented Dec 18, 2019

This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.
Thank you for your contributions.

@rails-bot rails-bot bot added the stale label Dec 18, 2019
@rails-bot rails-bot bot closed this Dec 25, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants