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

Caching #177

Closed
wants to merge 41 commits into from
Closed
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
7f749a9
Remove obsolete BodyDelegator
ixti Mar 18, 2014
0900813
Bump up YARDStick threshold
ixti Mar 18, 2014
0b4c444
HTTP Caching
Asmod4n Mar 18, 2014
4310b9e
Typo
Asmod4n Mar 19, 2014
2533e01
Small fixups
Asmod4n Mar 19, 2014
e7f753b
Fix a couple of specs and remove some rubocop warnings
pezra Feb 2, 2015
c4f1e45
basic specs for caching
pezra Feb 3, 2015
75d8068
extract some caching related helper objects and write specs
pezra Feb 3, 2015
b3499bd
improve cache options
pezra Feb 3, 2015
153fe3b
remove dead code
pezra Feb 3, 2015
69a343d
remove unused argument to make rubocop happy
pezra Feb 4, 2015
40fb8b6
remove debug puts
pezra Feb 4, 2015
bbfd8c3
remove many rubocop issues
pezra Feb 4, 2015
2a57ce3
swap `find` for `detect` to appease rubocop
pezra Feb 4, 2015
31a60f6
improve factoring of cache perform method
pezra Feb 4, 2015
08df766
further factoring improvements to cache perform
pezra Feb 4, 2015
f2346bd
Convert docs to yard
pezra Feb 4, 2015
4c10ea8
blank lines break builds, apparently
pezra Feb 4, 2015
bcf947e
Enable align parameters cop back again
ixti Feb 4, 2015
69dfa0a
Rename *WithCacheBehavior to *::Cached
ixti Feb 4, 2015
156ce9d
Refacor CacheControl helper. Renamed it to Cache::Headers
ixti Feb 4, 2015
0adc2a1
reenabled parameter alignment cop
pezra Feb 4, 2015
cd49187
Merge remote-tracking branch 'refs/remotes/upstream/caching' into cac…
pezra Feb 4, 2015
d22eaa5
remove a trailing line comment that made yard fail on ruby 2.2.0
pezra Feb 4, 2015
a33190f
rename HTTP::{Request,Response}::Cached to Caching
pezra Feb 4, 2015
71bc884
remove some unused attributes from Response
pezra Feb 4, 2015
5814dbb
Use rack-cache's storage implementations rather than rolling our own
pezra Feb 4, 2015
429a5a0
handle the rack-cache's result when using file stores
pezra Feb 9, 2015
79cdbb7
verify that cache's file store works
pezra Feb 9, 2015
7153878
allow both original requester and cache store to read response body
pezra Feb 9, 2015
cac374d
remove a layer of indirection (and code)
pezra Feb 9, 2015
042d904
fix a few minor stylistic thing for rubocop
pezra Feb 9, 2015
9956494
Merge remote-tracking branch 'refs/remotes/upstream/master' into caching
pezra Mar 4, 2015
182d5db
delay loading rack-cache until caching is requested
pezra Mar 4, 2015
2d1f847
reduce length of options to appease rubocop
pezra Mar 4, 2015
833deae
removed unused dependency
pezra Mar 9, 2015
88dc181
move require to top of file
pezra Mar 9, 2015
48f3e5c
simplify spec
pezra Mar 9, 2015
eeeb2c8
improved caching response spec
pezra Mar 9, 2015
86741e2
bury the lede in some specs
pezra Mar 9, 2015
c234d49
remove unnecessary/incorrect date addition code in request
pezra Mar 9, 2015
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,6 @@ Style/StringLiterals:

Style/TrivialAccessors:
Enabled: false

Style/AlignParameters:
Enabled: false
Copy link
Member

Choose a reason for hiding this comment

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

Please do not disable this cop.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. I think this rule encourages harder to read code but it is your house. :)

Copy link
Member

Choose a reason for hiding this comment

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

It encourages consistency of alignment 😜

2 changes: 1 addition & 1 deletion Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ end
require "yardstick/rake/verify"
Yardstick::Rake::Verify.new do |verify|
verify.require_exact_threshold = false
verify.threshold = 58
verify.threshold = 58.1
Copy link
Member

Choose a reason for hiding this comment

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

does this not support >= 50 or something like that?

Copy link
Member

Choose a reason for hiding this comment

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

It is. And verify.threshold = 58.1 in conjunction with verify.require_exact_threshold = false (above) means >= 58.1. We bump it up to try to enforce ourselves to "keep moving forward" to the 100% API documentation coverage :D

end

task :default => [:spec, :rubocop, :verify_measurements]
109 changes: 109 additions & 0 deletions lib/http/cache.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
require "time"
require "http/cache/cache_control"
require "http/cache/response_with_cache_behavior"
require "http/cache/request_with_cache_behavior"

module HTTP
class Cache
# NoOp cache. Always makes the request.
class NullCache
# @return [Response] the result of the provided block
# @yield [request, options] so that the request can actually be made
def perform(request, options)
yield(request, options)
end
end

# @return [Response] a cached response that is valid for the request or
# the result of executing the provided block
#
# @yield [request, options] on cache miss so that an actual
# request can be made
def perform(request, options, &request_performer)
req = RequestWithCacheBehavior.coerce(request)

invalidate_cache(req) if req.invalidates_cache?

get_response(req, options, request_performer)
end

protected

# @return [Response] the response to the request, either from the
# cache or by actually making the request
def get_response(req, options, request_performer)
cached_resp = cache_lookup(req)
return cached_resp if cached_resp && !cached_resp.stale?

# cache miss

actual_req = if cached_resp
req.conditional_on_changes_to(cached_resp)
else
req
end
actual_resp = make_request(actual_req, options, request_performer)

handle_response(cached_resp, actual_resp, req)
end

# @returns [Response] the most useful of the responses after
# updating the cache as appropriate
def handle_response(cached_resp, actual_resp, req)
if actual_resp.status.not_modified? && cached_resp
cached_resp.validated!(actual_resp)
store_in_cache(req, cached_resp)
return cached_resp

elsif req.cacheable? && actual_resp.cacheable?
store_in_cache(req, actual_resp)
return actual_resp

else
return actual_resp
end
end

# @return [ResponseWithCacheBehavior] the actual response returned
# by request_performer
def make_request(req, options, request_performer)
req.sent_at = Time.now
ResponseWithCacheBehavior.coerce(request_performer.call(req, options)).tap do |resp|
resp.received_at = Time.now
resp.requested_at = req.sent_at
end
end

# @return [ResponseWithCacheBehavior] the cached response for the
# request or nil if there isn't one
def cache_lookup(request)
return nil if request.skips_cache?
c = @cache_adapter.lookup(request)
if c
ResponseWithCacheBehavior.coerce(c)
else
nil
end
end

# Store response in cache
#
# @return [nil]
def store_in_cache(request, response)
@cache_adapter.store(request, response)
nil
end

# Invalidate all response from the requested resource
#
# @return [nil]
def invalidate_cache(request)
@cache_adapter.invalidate(request.uri)
end

# Inits a new instance
def initialize(adapter = HTTP::Cache::InMemoryCache.new)
@cache_adapter = adapter
end
end
end
91 changes: 91 additions & 0 deletions lib/http/cache/cache_control.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
module HTTP
class Cache
# Convenience methods around cache control headers.
class CacheControl
# @return [Boolean] does this message force revalidation
def forces_revalidation?
must_revalidate? || max_age == 0
end

# @return [Boolean] does the cache control include 'must-revalidate'
def must_revalidate?
matches?(/\bmust-revalidate\b/i)
end

# @return [Boolean] does the cache control include 'no-cache'
def no_cache?
matches?(/\bno-cache\b/i)
end

# @return [Boolean] does the cache control include 'no-stor'
def no_store?
matches?(/\bno-store\b/i)
end

# @return [Boolean] does the cache control include 'public'
def public?
matches?(/\bpublic\b/i)
end

# @return [Boolean] does the cache control include 'private'
def private?
matches?(/\bprivate\b/i)
end

# @return [Numeric] the max number of seconds this message is
# considered fresh.
def max_age
explicit_max_age || seconds_til_expires || Float::INFINITY
end

# @return [Boolean] is the vary header set to '*'
def vary_star?
headers.get("Vary").any? { |v| "*" == v.strip }
end

protected

attr_reader :headers

# @return [Boolean] true when cache-control header matches the pattern
def matches?(pattern)
headers.get("Cache-Control").any? { |v| v =~ pattern }
end

# @return [Numeric] number of seconds until the time in the
# expires header is reached.
#
# ---
# Some servers send a "Expire: -1" header which must be treated as expired
def seconds_til_expires
headers.get("Expires")
.map(&method(:to_time_or_epoch))
.compact
.map { |e| e - Time.now }
.map { |a| a < 0 ? 0 : a } # age is 0 if it is expired
.max
end

# @return [Time] parses t_str at a time; if that fails returns epoch time
def to_time_or_epoch(t_str)
Time.httpdate(t_str)
rescue ArgumentError
Time.at(0)
end

# @return [Numeric] the value of the max-age component of cache control
def explicit_max_age
headers.get("Cache-Control")
.map { |v| (/max-age=(\d+)/i).match(v) }
.compact
.map { |m| m[1].to_i }
.max
end

# Inits a new instance
def initialize(message)
@headers = message.headers
end
end
end
end
69 changes: 69 additions & 0 deletions lib/http/cache/collection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
require "http/headers"

module HTTP
class Cache
# Collection of all entries in the cache.
class CacheEntryCollection
include Enumerable

# @yield [CacheEntry] each successive entry in the cache.
def each(&block)
@entries.each(&block)
end

# @return [Response] the response for the request or nil if
# there isn't one.
def [](request)
entry = detect { |e| e.valid_for?(request) }
entry.response if entry
end

# @return [Response] the specified response after inserting it
# into the cache.
def []=(request, response)
@entries.delete_if { |entry| entry.valid_for?(request) }
@entries << CacheEntry.new(request, response)
response
end

protected

def initialize
@entries = []
end
end

# An entry for a single response in the cache
class CacheEntry
attr_reader :request, :response

# @return [Boolean] true iff this entry is valid for the
# request.
def valid_for?(request)
request.uri == @request.uri &&
select_request_headers.all? do |key, value|
request.headers[key] == value
end
end

protected

# @return [Hash] the headers that matter of matching requests to
# this response.
def select_request_headers
headers = HTTP::Headers.new

@response.headers.get("Vary").flat_map { |v| v.split(",") }.uniq.each do |name|
name.strip!
headers[name] = @request.headers[name] if @request.headers[name]
end

headers.to_h
end

def initialize(request, response)
@request, @response = request, response
end
end
end
end
39 changes: 39 additions & 0 deletions lib/http/cache/in_memory_cache.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
require "thread"
require "http/cache/collection"

module HTTP
class Cache
class InMemoryCache
# @return [Response] the response for the request or nil if one
# isn't found
def lookup(request)
@mutex.synchronize do
response = @collection[request.uri.to_s][request]
response.authoritative = false if response
response
end
end

# Stores response to be looked up later.
def store(request, response)
@mutex.synchronize do
@collection[request.uri.to_s][request] = response
end
end

# Invalidates the all responses from the specified resource.
def invalidate(uri)
@mutex.synchronize do
@collection.delete(uri.to_s)
end
end

protected

def initialize
@mutex = Mutex.new
@collection = Hash.new { |h, k| h[k] = CacheEntryCollection.new }
end
end
end
end
Loading