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

Implement H2 Early Hints for Rails #30744

Merged
merged 1 commit into from Oct 4, 2017

Conversation

@eileencodes
Member

eileencodes commented Sep 28, 2017

When puma/puma#1403 is merged Puma will support the Early Hints status
code for sending assets before a request has finished.

While the Early Hints spec is still in draft, this PR prepares Rails to
allowing this status code.

If the proxy server supports Early Hints, it will send H2 pushes to the
client.

This PR adds a method for setting Early Hints Link headers via Rails,
and also automatically sends Early Hints if supported from the
stylesheet_link_tag and the javascript_include_tag.

Once puma supports Early Hints the --early-hints argument can be
passed to the server to enable this. Note that for Early Hints to work
in the browser the requirements are 1) a proxy that can handle H2,
and 2) HTTPS.

To start the server with Early Hints enabled pass --early-hints to rails s.

This has been verified to work with h2o, Puma, and Rails with Chrome.

The commit adds a new option to the rails server to enable early hints
for Puma.

Early Hints spec:
https://tools.ietf.org/html/draft-ietf-httpbis-early-hints-04

cc/ @tenderlove who I worked with on this feature


In the screenshot below you can see the pushes from early hints.

screen shot 2017-09-26 at 1 26 57 pm

@eileencodes eileencodes self-assigned this Sep 28, 2017

@eileencodes eileencodes added this to the 5.2.0 milestone Sep 28, 2017

@kaspth

iirc, sprockets-rails overrides these helpers, but that won't make it so that the early hints will be removed, right?

Looking great otherwise!

Show outdated Hide outdated actionpack/lib/action_dispatch/http/request.rb
Show outdated Hide outdated actionpack/lib/action_dispatch/http/request.rb
Show outdated Hide outdated actionpack/test/dispatch/request_test.rb
Show outdated Hide outdated actionview/lib/action_view/helpers/asset_tag_helper.rb
Show outdated Hide outdated actionview/lib/action_view/helpers/asset_tag_helper.rb
tag_options = {
"src" => path_to_javascript(source, path_options)
"src" => href
}.merge!(options)
content_tag("script".freeze, "", tag_options)

This comment has been minimized.

@kaspth

kaspth Sep 28, 2017

Member

Would it make sense to change this to tag.script(src: href, **options) while we're here? (Though that would probably necessitate shifting to symbolize_keys above).

@kaspth

kaspth Sep 28, 2017

Member

Would it make sense to change this to tag.script(src: href, **options) while we're here? (Though that would probably necessitate shifting to symbolize_keys above).

This comment has been minimized.

@matthewd

matthewd Sep 28, 2017

Member

Is that desirable in general in internal stuff? The improvement for user code is nice, but it feels like fairly pointless (and comparatively allocation-heavy) indirection for us.

@matthewd

matthewd Sep 28, 2017

Member

Is that desirable in general in internal stuff? The improvement for user code is nice, but it feels like fairly pointless (and comparatively allocation-heavy) indirection for us.

This comment has been minimized.

@kaspth

kaspth Sep 28, 2017

Member

Right, it's a bit of a back and forth. I was focusing on removing the freeze call. Let's defer to some other time, since it's out of scope for this particular thing 👍

@kaspth

kaspth Sep 28, 2017

Member

Right, it's a bit of a back and forth. I was focusing on removing the freeze call. Let's defer to some other time, since it's out of scope for this particular thing 👍

def send_early_hints(links)
return unless env["rack.early_hints"]
env["rack.early_hints"].call(links)

This comment has been minimized.

@grosser

grosser Sep 28, 2017

Contributor

could avoid calling doing the same lookup twice:

env["rack.early_hints"]&.call(links)

or

callback = env["rack.early_hints"]
callback.call(links) if callback
@grosser

grosser Sep 28, 2017

Contributor

could avoid calling doing the same lookup twice:

env["rack.early_hints"]&.call(links)

or

callback = env["rack.early_hints"]
callback.call(links) if callback

This comment has been minimized.

@matthewd

matthewd Sep 29, 2017

Member

It's a hash lookup. I'm sure it'll be fine.

@matthewd

matthewd Sep 29, 2017

Member

It's a hash lookup. I'm sure it'll be fine.

This comment has been minimized.

@eileencodes

eileencodes Oct 3, 2017

Member

I've considered this but agree with @matthewd.

Drying things up isn't a benefit if the code is harder to read and understand and the performance impact of calling a hash lookup twice will be negligible.

@eileencodes

eileencodes Oct 3, 2017

Member

I've considered this but agree with @matthewd.

Drying things up isn't a benefit if the code is harder to read and understand and the performance impact of calling a hash lookup twice will be negligible.

This comment has been minimized.

@Deradon

Deradon Dec 6, 2017

Contributor

I was just curious about the performance impact, so I did a quick benchmarking.

TL;DR

... the performance impact of calling a hash lookup twice will be negligible.

Details

# 1.18x slower than v2 if env["rack.early_hints"]
def v1
  return unless env["rack.early_hints"]
  env["rack.early_hints"].call(links)
end

# 1.09x slower than v1 IF NOT env["rack.early_hints"]
def v2
  callback = env["rack.early_hints"]
  callback.call(links) if callback
end
Click to show code
#!/usr/bin/env ruby

require 'benchmark/ips'
require 'date'

LOOKUP = { "foo" => ->() {} }

def happy_case
  return unless LOOKUP["foo"]

  LOOKUP["foo"].call()
end

def happy_case_with_assignment
  callback = LOOKUP["foo"]

  callback.call() if callback
end

def alternase_case
  return unless LOOKUP["bar"]

  LOOKUP["bar"].call()
end

def alternase_case_with_assignment
  callback = LOOKUP["bar"]

  callback.call() if callback
end


# == Result
#
# happy_case: 1.18x slower
Benchmark.ips do |x|
  # Configure the number of seconds used during
  # the warmup phase (default 2) and calculation phase (default 5)
  x.config(time: 5, warmup: 2)

  x.report("happy_case") { happy_case }
  x.report("happy_case_with_assignment") { happy_case_with_assignment }

  # Compare the iterations per second of the various reports!
  x.compare!
end

# == Result
# 
# alternase_case_with_assignment: 1.09x slower
Benchmark.ips do |x|
  # Configure the number of seconds used during
  # the warmup phase (default 2) and calculation phase (default 5)
  x.config(time: 5, warmup: 2)

  x.report("alternase_case") { alternase_case }
  x.report("alternase_case_with_assignment") { alternase_case_with_assignment }

  # Compare the iterations per second of the various reports!
  x.compare!
end

__END__

Calculating -------------------------------------
          happy_case   111.305k i/100ms
happy_case_with_assignment
                       127.680k i/100ms
-------------------------------------------------
          happy_case      3.704M (± 2.1%) i/s -     18.588M
happy_case_with_assignment
                          4.353M (± 1.2%) i/s -     21.833M

Comparison:
happy_case_with_assignment:  4353466.9 i/s
          happy_case:  3704451.9 i/s - 1.18x slower

Calculating -------------------------------------
      alternase_case   146.388k i/100ms
alternase_case_with_assignment
                       154.871k i/100ms
-------------------------------------------------
      alternase_case      7.631M (± 2.9%) i/s -     38.207M
alternase_case_with_assignment
                          6.977M (± 6.8%) i/s -     34.846M

Comparison:
      alternase_case:  7630584.3 i/s
alternase_case_with_assignment:  6977239.4 i/s - 1.09x slower
@Deradon

Deradon Dec 6, 2017

Contributor

I was just curious about the performance impact, so I did a quick benchmarking.

TL;DR

... the performance impact of calling a hash lookup twice will be negligible.

Details

# 1.18x slower than v2 if env["rack.early_hints"]
def v1
  return unless env["rack.early_hints"]
  env["rack.early_hints"].call(links)
end

# 1.09x slower than v1 IF NOT env["rack.early_hints"]
def v2
  callback = env["rack.early_hints"]
  callback.call(links) if callback
end
Click to show code
#!/usr/bin/env ruby

require 'benchmark/ips'
require 'date'

LOOKUP = { "foo" => ->() {} }

def happy_case
  return unless LOOKUP["foo"]

  LOOKUP["foo"].call()
end

def happy_case_with_assignment
  callback = LOOKUP["foo"]

  callback.call() if callback
end

def alternase_case
  return unless LOOKUP["bar"]

  LOOKUP["bar"].call()
end

def alternase_case_with_assignment
  callback = LOOKUP["bar"]

  callback.call() if callback
end


# == Result
#
# happy_case: 1.18x slower
Benchmark.ips do |x|
  # Configure the number of seconds used during
  # the warmup phase (default 2) and calculation phase (default 5)
  x.config(time: 5, warmup: 2)

  x.report("happy_case") { happy_case }
  x.report("happy_case_with_assignment") { happy_case_with_assignment }

  # Compare the iterations per second of the various reports!
  x.compare!
end

# == Result
# 
# alternase_case_with_assignment: 1.09x slower
Benchmark.ips do |x|
  # Configure the number of seconds used during
  # the warmup phase (default 2) and calculation phase (default 5)
  x.config(time: 5, warmup: 2)

  x.report("alternase_case") { alternase_case }
  x.report("alternase_case_with_assignment") { alternase_case_with_assignment }

  # Compare the iterations per second of the various reports!
  x.compare!
end

__END__

Calculating -------------------------------------
          happy_case   111.305k i/100ms
happy_case_with_assignment
                       127.680k i/100ms
-------------------------------------------------
          happy_case      3.704M (± 2.1%) i/s -     18.588M
happy_case_with_assignment
                          4.353M (± 1.2%) i/s -     21.833M

Comparison:
happy_case_with_assignment:  4353466.9 i/s
          happy_case:  3704451.9 i/s - 1.18x slower

Calculating -------------------------------------
      alternase_case   146.388k i/100ms
alternase_case_with_assignment
                       154.871k i/100ms
-------------------------------------------------
      alternase_case      7.631M (± 2.9%) i/s -     38.207M
alternase_case_with_assignment
                          6.977M (± 6.8%) i/s -     34.846M

Comparison:
      alternase_case:  7630584.3 i/s
alternase_case_with_assignment:  6977239.4 i/s - 1.09x slower

This comment has been minimized.

@mathieujobin

mathieujobin Apr 20, 2018

interesting, would have been nice to see how the &. syntax compares.

@mathieujobin

mathieujobin Apr 20, 2018

interesting, would have been nice to see how the &. syntax compares.

@@ -161,7 +162,8 @@ def server_options
daemonize: options[:daemon],
pid: pid,
caching: options["dev-caching"],
restart_cmd: restart_command
restart_cmd: restart_command,
early_hints: early_hints

This comment has been minimized.

@grosser

grosser Sep 28, 2017

Contributor

use it directly like dev-caching / daemon ?

@grosser

grosser Sep 28, 2017

Contributor

use it directly like dev-caching / daemon ?

This comment has been minimized.

@eileencodes

eileencodes Oct 3, 2017

Member

I put the documentation in the early hints option. It's passed in like --early-hints or can be set in the puma.config with early_hints(true).

@eileencodes

eileencodes Oct 3, 2017

Member

I put the documentation in the early hints option. It's passed in like --early-hints or can be set in the puma.config with early_hints(true).

@grosser

This comment has been minimized.

Show comment
Hide comment
@grosser

grosser Sep 29, 2017

Contributor
Contributor

grosser commented Sep 29, 2017

@eileencodes

This comment has been minimized.

Show comment
Hide comment
@eileencodes

eileencodes Oct 3, 2017

Member

@kaspth I've fixed the typos and other recommendations you made. Thanks for the review!

FYI, this is on hold until the Puma branch is merged.

Member

eileencodes commented Oct 3, 2017

@kaspth I've fixed the typos and other recommendations you made. Thanks for the review!

FYI, this is on hold until the Puma branch is merged.

@eileencodes eileencodes changed the title from WIP: Implement H2 Early Hints for Rails to Implement H2 Early Hints for Rails Oct 4, 2017

Implement H2 Early Hints for Rails
When puma/puma#1403 is merged Puma will support the Early Hints status
code for sending assets before a request has finished.

While the Early Hints spec is still in draft, this PR prepares Rails to
allowing this status code.

If the proxy server supports Early Hints, it will send H2 pushes to the
client.

This PR adds a method for setting Early Hints Link headers via Rails,
and also automatically sends Early Hints if supported from the
`stylesheet_link_tag` and the `javascript_include_tag`.

Once puma supports Early Hints the `--early-hints` argument can be
passed to the server to enable this or set in the puma config with
`early_hints(true)`. Note that for Early Hints to work
in the browser the requirements are 1) a proxy that can handle H2,
and 2) HTTPS.

To start the server with Early Hints enabled pass `--early-hints` to
`rails s`.

This has been verified to work with h2o, Puma, and Rails with Chrome.

The commit adds a new option to the rails server to enable early hints
for Puma.

Early Hints spec:
https://tools.ietf.org/html/draft-ietf-httpbis-early-hints-04

[Eileen M. Uchitelle, Aaron Patterson]

@eileencodes eileencodes merged commit bd2542b into rails:master Oct 4, 2017

2 checks passed

codeclimate All good!
Details
continuous-integration/travis-ci/pr The Travis CI build passed
Details

@eileencodes eileencodes deleted the eileencodes:early-hints branch Oct 4, 2017

@ffmike ffmike referenced this pull request Oct 8, 2017

Closed

Broken for Rails 5.2 alpha #199

@madmax

This comment has been minimized.

Show comment
Hide comment
@madmax

madmax Oct 8, 2017

Contributor

@eileencodes What about javascript_pack_tag? It will also work with it?

Contributor

madmax commented Oct 8, 2017

@eileencodes What about javascript_pack_tag? It will also work with it?

@eileencodes

This comment has been minimized.

Show comment
Hide comment
@eileencodes

eileencodes Oct 9, 2017

Member

@madmax Is that implemented by webpacker? I haven't used Rails webpacker and I don't see a javascript_pack_tag in Rails.

Member

eileencodes commented Oct 9, 2017

@madmax Is that implemented by webpacker? I haven't used Rails webpacker and I don't see a javascript_pack_tag in Rails.

@madmax

This comment has been minimized.

Show comment
Hide comment
@madmax

madmax Oct 9, 2017

Contributor

Yes it is part of webpacker gem. And there is also manifest.json can't we use it?

Contributor

madmax commented Oct 9, 2017

Yes it is part of webpacker gem. And there is also manifest.json can't we use it?

@eileencodes

This comment has been minimized.

Show comment
Hide comment
@eileencodes

eileencodes Oct 9, 2017

Member

At this moment I don't plan on implementing in webpacker but I don't see a reason to not implement it there if the tag has access to the request object and someone wants to work on that.

Member

eileencodes commented Oct 9, 2017

At this moment I don't plan on implementing in webpacker but I don't see a reason to not implement it there if the tag has access to the request object and someone wants to work on that.

@guilleiguaran

This comment has been minimized.

Show comment
Hide comment
@guilleiguaran

guilleiguaran Oct 9, 2017

Member

IIRC javascript_pack_tag just calls Rails' default javascript_include_tag underneath.

Member

guilleiguaran commented Oct 9, 2017

IIRC javascript_pack_tag just calls Rails' default javascript_include_tag underneath.

@eileencodes

This comment has been minimized.

Show comment
Hide comment
@eileencodes

eileencodes Oct 9, 2017

Member

Ok then I think it will Just Work™️ 😄

Member

eileencodes commented Oct 9, 2017

Ok then I think it will Just Work™️ 😄

@printercu

This comment has been minimized.

Show comment
Hide comment
@printercu

printercu Oct 10, 2017

Contributor

According to code, links for early hints are formatted even when server does not support early hints. Should this be better enabled with early_hints: true option?

Also this should be a better option to provide method to send hints from controller, this way browser can start loading scripts even before server starts business logic and page rendering. Layout rendering is the last stage (except streaming mode) of request processing, hints can be even earlier (in before_action?).

WDYT?

Contributor

printercu commented Oct 10, 2017

According to code, links for early hints are formatted even when server does not support early hints. Should this be better enabled with early_hints: true option?

Also this should be a better option to provide method to send hints from controller, this way browser can start loading scripts even before server starts business logic and page rendering. Layout rendering is the last stage (except streaming mode) of request processing, hints can be even earlier (in before_action?).

WDYT?

sources_tags = sources.uniq.map { |source|
href = path_to_stylesheet(source, path_options)
early_hints_links << "<#{href}>; rel=preload; as=stylesheet"

This comment has been minimized.

@mengqing

mengqing Jan 31, 2018

According to the example described in https://w3c.github.io/preload/#as-attribute, it seems that the as target attribute for stylesheets should be style instead of stylesheet

@mengqing

mengqing Jan 31, 2018

According to the example described in https://w3c.github.io/preload/#as-attribute, it seems that the as target attribute for stylesheets should be style instead of stylesheet

@pjforde1978

This comment has been minimized.

Show comment
Hide comment
@pjforde1978

pjforde1978 Aug 16, 2018

Hey @eileencodes! Thanks so much for your work getting early hints supported in Rails.

I'm seeing some intermittent socket timeout errors when I have --early-hints enabled on my app, which is hosted on Heroku. It's only showing up about once a day, and only in production.

Some lucky visitor's request is throwing one Puma:ConnectionError for each javascript_include_tag in my application layout. If one call fails, they all fail in proper sequence. I've never experienced the end-result personally, leading me to guess that the visitor probably won't notice as their script tags would just load normally.

Any hunches?

Aug 16 12:34:37 /app/vendor/bundle/ruby/2.4.0/gems/puma-3.11.4/lib/puma/server.rb:972:in 'rescue in fast_write' 
Aug 16 12:34:37 /app/vendor/bundle/ruby/2.4.0/gems/puma-3.11.4/lib/puma/server.rb:963:in 'fast_write' 
Aug 16 12:34:37 /app/vendor/bundle/ruby/2.4.0/gems/puma-3.11.4/lib/puma/server.rb:609:in 'block in handle_request' 
Aug 16 12:34:37 /app/vendor/bundle/ruby/2.4.0/gems/actionpack-5.2.0/lib/action_dispatch/http/request.rb:217:in 'send_early_hints' 
Aug 16 12:34:37 /app/vendor/bundle/ruby/2.4.0/gems/actionview-5.2.0/lib/action_view/helpers/asset_tag_helper.rb:96:in 'javascript_include_tag' 
Aug 16 12:34:37 /app/vendor/bundle/ruby/2.4.0/gems/sprockets-rails-3.2.1/lib/sprockets/rails/helper.rb:156:in 'block in javascript_include_tag' 
Aug 16 12:34:37 /app/vendor/bundle/ruby/2.4.0/gems/sprockets-rails-3.2.1/lib/sprockets/rails/helper.rb:154:in 'map' 
Aug 16 12:34:37 /app/vendor/bundle/ruby/2.4.0/gems/sprockets-rails-3.2.1/lib/sprockets/rails/helper.rb:154:in 'javascript_include_tag' 
Aug 16 12:34:37 /app/app/views/layouts/application.html.erb:97:in 'block in _app_views_layouts_application_html_erb__4118899528439397404_70152019751760' 
Aug 16 12:34:37 /app/app/views/layouts/application.html.erb:95:in 'each' 
Aug 16 12:34:37 /app/app/views/layouts/application.html.erb:95:in '_app_views_layouts_application_html_erb__4118899528439397404_70152019751760'```

pjforde1978 commented Aug 16, 2018

Hey @eileencodes! Thanks so much for your work getting early hints supported in Rails.

I'm seeing some intermittent socket timeout errors when I have --early-hints enabled on my app, which is hosted on Heroku. It's only showing up about once a day, and only in production.

Some lucky visitor's request is throwing one Puma:ConnectionError for each javascript_include_tag in my application layout. If one call fails, they all fail in proper sequence. I've never experienced the end-result personally, leading me to guess that the visitor probably won't notice as their script tags would just load normally.

Any hunches?

Aug 16 12:34:37 /app/vendor/bundle/ruby/2.4.0/gems/puma-3.11.4/lib/puma/server.rb:972:in 'rescue in fast_write' 
Aug 16 12:34:37 /app/vendor/bundle/ruby/2.4.0/gems/puma-3.11.4/lib/puma/server.rb:963:in 'fast_write' 
Aug 16 12:34:37 /app/vendor/bundle/ruby/2.4.0/gems/puma-3.11.4/lib/puma/server.rb:609:in 'block in handle_request' 
Aug 16 12:34:37 /app/vendor/bundle/ruby/2.4.0/gems/actionpack-5.2.0/lib/action_dispatch/http/request.rb:217:in 'send_early_hints' 
Aug 16 12:34:37 /app/vendor/bundle/ruby/2.4.0/gems/actionview-5.2.0/lib/action_view/helpers/asset_tag_helper.rb:96:in 'javascript_include_tag' 
Aug 16 12:34:37 /app/vendor/bundle/ruby/2.4.0/gems/sprockets-rails-3.2.1/lib/sprockets/rails/helper.rb:156:in 'block in javascript_include_tag' 
Aug 16 12:34:37 /app/vendor/bundle/ruby/2.4.0/gems/sprockets-rails-3.2.1/lib/sprockets/rails/helper.rb:154:in 'map' 
Aug 16 12:34:37 /app/vendor/bundle/ruby/2.4.0/gems/sprockets-rails-3.2.1/lib/sprockets/rails/helper.rb:154:in 'javascript_include_tag' 
Aug 16 12:34:37 /app/app/views/layouts/application.html.erb:97:in 'block in _app_views_layouts_application_html_erb__4118899528439397404_70152019751760' 
Aug 16 12:34:37 /app/app/views/layouts/application.html.erb:95:in 'each' 
Aug 16 12:34:37 /app/app/views/layouts/application.html.erb:95:in '_app_views_layouts_application_html_erb__4118899528439397404_70152019751760'```
@eileencodes

This comment has been minimized.

Show comment
Hide comment
@eileencodes

eileencodes Aug 25, 2018

Member

Hey @pjforde1978 I'm not sure what's going on but I suspect that this is more on the puma side than the Rails side since Rails just adds the tags, but Puma makes the early hints actually work. Can you open an issue over there including what your application is using (Ruby version, Rails version, Puma version, proxy you're using), and anything else that can help us track this down. Unfortunately we're not able to use this in prod at GitHub yet so I'm not sure where to start looking.

Member

eileencodes commented Aug 25, 2018

Hey @pjforde1978 I'm not sure what's going on but I suspect that this is more on the puma side than the Rails side since Rails just adds the tags, but Puma makes the early hints actually work. Can you open an issue over there including what your application is using (Ruby version, Rails version, Puma version, proxy you're using), and anything else that can help us track this down. Unfortunately we're not able to use this in prod at GitHub yet so I'm not sure where to start looking.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment