rack-cache-buster 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/README.markdown +13 -17
- data/lib/rack/cache_buster.rb +34 -29
- data/lib/rack/cache_buster/cache_control_header.rb +46 -0
- data/recipes/rack_cache_buster_recipes.rb +57 -0
- data/test/cache_busting_test.rb +18 -11
- metadata +5 -4
- data/lib/rack/cache_buster/auto.rb +0 -13
- data/test/auto_test.rb +0 -70
data/README.markdown
CHANGED
@@ -1,25 +1,17 @@
|
|
1
1
|
# Rack::CacheBuster
|
2
2
|
|
3
|
-
Place this in your rack stack and all caching will be gone.
|
4
|
-
|
5
|
-
Add an optional key to salt the ETags in and out of your app.
|
6
|
-
|
7
|
-
## Usage:
|
8
|
-
use Rack::CacheBuster, APP_VERSION
|
9
|
-
|
10
|
-
# Rack::CacheBuster::Auto
|
11
|
-
|
12
3
|
Use to wind down a running app when needed.
|
13
4
|
|
14
|
-
|
5
|
+
Limits the max-age to the contents of the `WIND_DOWN` file, and salts the ETags to unsure clients see different deployments as having different contents.
|
15
6
|
|
16
7
|
## Usage:
|
8
|
+
require "rack/cache_buster"
|
9
|
+
WIND_DOWN_TIME = Time.parse(File.read(File.join(Rails.root, "WIND_DOWN"))) rescue nil
|
10
|
+
APP_VERSION = File.read(File.join(Rails.root, "REVISION")) rescue nil
|
11
|
+
|
12
|
+
…
|
17
13
|
|
18
|
-
|
19
|
-
|
20
|
-
ensure you have a REVISION file on your production servers, capistrano does this by default.
|
21
|
-
|
22
|
-
use Rack::CacheBuster::Auto, File.join(Rails.root, "REVISION"), File.join(Rails.root, "WIND_DOWN")
|
14
|
+
use Rack::CacheBuster, APP_VERSION, WIND_DOWN_TIME
|
23
15
|
|
24
16
|
### Before you deploy:
|
25
17
|
|
@@ -29,6 +21,10 @@ ensure you have a REVISION file on your production servers, capistrano does this
|
|
29
21
|
|
30
22
|
* Restart all instances (touch tmp/restart.txt will be fine for passenger apps).
|
31
23
|
|
32
|
-
|
24
|
+
* Once all the caches should have expired you can deploy as normal.
|
25
|
+
|
26
|
+
## Notes
|
27
|
+
|
28
|
+
If you are using Rack::Cache, you can safely put the CacheBuster below it in the stack, but only if you clear Rack::Cache's cache upon deploy.
|
33
29
|
|
34
|
-
|
30
|
+
This is actually the recommended approach as it will prevent the increased amount of hits between `WIND_DOWN_TIME` and your actual deployment from making it through to your app.
|
data/lib/rack/cache_buster.rb
CHANGED
@@ -2,53 +2,58 @@ require 'digest/md5'
|
|
2
2
|
|
3
3
|
module Rack
|
4
4
|
class CacheBuster
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
@
|
9
|
-
if key
|
10
|
-
@key = "-"+Digest::MD5.hexdigest(key).freeze
|
11
|
-
@key_regexp = /#{@key}/.freeze
|
12
|
-
end
|
5
|
+
def initialize(app, key, target_time = nil)
|
6
|
+
@app, @target_time = app, target_time
|
7
|
+
@key = "-"+Digest::MD5.hexdigest(key || "blank-key").freeze
|
8
|
+
@key_regexp = /#{@key}/.freeze
|
13
9
|
end
|
14
10
|
|
15
11
|
def call(env)
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
status, headers, body = app.call(env)
|
20
|
-
|
21
|
-
headers = headers.dup
|
22
|
-
patch_etag!(headers) if key
|
23
|
-
bust_cache!(headers)
|
24
|
-
|
25
|
-
[status, headers, body]
|
12
|
+
status, headers, body = app.call(unpatch_etag(env))
|
13
|
+
[status, patch_etag(limit_cache(headers)), body]
|
26
14
|
end
|
27
15
|
|
28
16
|
protected
|
29
17
|
QUOTE_STRIPPER=/^"|"$/.freeze
|
30
18
|
ETAGGY_HEADERS = ["HTTP_IF_NONE_MATCH", "HTTP_IF_MATCH", "HTTP_IF_RANGE"].freeze
|
31
19
|
ETag = "ETag".freeze
|
32
|
-
CacheControl = "Cache-Control".freeze
|
33
20
|
|
34
21
|
attr_reader :app
|
35
22
|
attr_reader :key
|
36
23
|
|
37
24
|
|
38
|
-
def
|
39
|
-
headers
|
25
|
+
def limit_cache(headers)
|
26
|
+
return headers if @target_time.nil?
|
27
|
+
cache_control_header = CacheControlHeader.new(headers)
|
28
|
+
if cache_control_header.expired?
|
29
|
+
headers
|
30
|
+
else
|
31
|
+
cache_control_header.expire_time = [@target_time, cache_control_header.expire_time].min
|
32
|
+
headers.merge(CacheControlHeader::CacheControl => cache_control_header.to_s)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def unpatch_etag(headers)
|
37
|
+
p headers
|
38
|
+
ETAGGY_HEADERS.inject(headers){|memo, k|
|
39
|
+
p [k, memo[k], strip_etag(memo[k])] if memo[k]
|
40
|
+
memo.has_key?(k) ? memo.merge(k => strip_etag(memo[k])) : memo
|
41
|
+
}
|
40
42
|
end
|
41
43
|
|
42
|
-
def
|
43
|
-
|
44
|
-
headers[k] = headers[k].gsub(@key_regexp, "") if headers[k]
|
45
|
-
end
|
44
|
+
def strip_etag(s)
|
45
|
+
s.gsub(@key_regexp, "")
|
46
46
|
end
|
47
47
|
|
48
|
-
def
|
49
|
-
|
50
|
-
|
51
|
-
headers[ETag] = %Q{"#{stripped_tag}#{key}"}
|
48
|
+
def modify_etag(s)
|
49
|
+
s = s.to_s.gsub(QUOTE_STRIPPER, "")
|
50
|
+
s.empty? ? s : %Q{"#{s}#{key}"}
|
52
51
|
end
|
52
|
+
|
53
|
+
def patch_etag(headers)
|
54
|
+
headers.merge(ETag => modify_etag(headers[ETag]))
|
55
|
+
end
|
56
|
+
|
57
|
+
autoload :CacheControlHeader, "rack/cache_buster/cache_control_header"
|
53
58
|
end
|
54
59
|
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require "rack/cache_buster"
|
2
|
+
|
3
|
+
class Rack::CacheBuster::CacheControlHeader < Rack::CacheBuster
|
4
|
+
CacheControl = "Cache-Control".freeze
|
5
|
+
Age = "Age".freeze
|
6
|
+
MaxAge = "max-age".freeze
|
7
|
+
|
8
|
+
def initialize(env)
|
9
|
+
@age = env[Age].to_i
|
10
|
+
@max_age = 0
|
11
|
+
if env[CacheControl]
|
12
|
+
parts = env[CacheControl].split(/ *, */)
|
13
|
+
settings, options = parts.partition{|part| part =~ /=/ }
|
14
|
+
settings = settings.inject({}){|acc, part|
|
15
|
+
k, v = part.split(/ *= */, 2)
|
16
|
+
acc.merge(k => v)
|
17
|
+
}
|
18
|
+
@max_age = settings.delete(MaxAge).to_i
|
19
|
+
@other_parts = options + settings.map{|k,v| "#{k}=#{v}"}
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def expires_in
|
24
|
+
@max_age - @age
|
25
|
+
end
|
26
|
+
|
27
|
+
def expired?
|
28
|
+
expires_in <= 0
|
29
|
+
end
|
30
|
+
|
31
|
+
def expire_time
|
32
|
+
Time.now + expires_in
|
33
|
+
end
|
34
|
+
|
35
|
+
def expire_time=(t)
|
36
|
+
@max_age = [t.to_i - Time.now.to_i + @age, @age].max
|
37
|
+
end
|
38
|
+
|
39
|
+
def update_env(env)
|
40
|
+
env[CacheControl] = to_s
|
41
|
+
end
|
42
|
+
|
43
|
+
def to_s
|
44
|
+
["max-age=#{@max_age}", *@other_parts].join(", ")
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
def get_wind_down_time
|
2
|
+
target_time_string = capture("[ -f #{current_path}/WIND_DOWN ] && cat #{current_path}/WIND_DOWN || echo -n ")
|
3
|
+
if target_time_string.empty?
|
4
|
+
nil
|
5
|
+
else
|
6
|
+
Time.parse(target_time_string)
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
Capistrano::Configuration.instance(:must_exist).load do
|
11
|
+
unless exists? :wind_down_time
|
12
|
+
set :wind_down_time, 60*60 # 1.hour
|
13
|
+
end
|
14
|
+
|
15
|
+
namespace :cache do
|
16
|
+
desc "Start winding down the current deployment."
|
17
|
+
task :winddown do
|
18
|
+
run "echo '#{Time.now + wind_down_time}' > #{current_path}/WIND_DOWN"
|
19
|
+
deploy.restart
|
20
|
+
end
|
21
|
+
|
22
|
+
desc "Cancel an in progress wind down."
|
23
|
+
task :cancel_winddown do
|
24
|
+
run "rm -f #{current_path}/WIND_DOWN"
|
25
|
+
deploy.restart
|
26
|
+
end
|
27
|
+
|
28
|
+
desc "Check that wind down has completed (or was never started)."
|
29
|
+
task :check_ready_to_deploy do
|
30
|
+
if target_time = get_wind_down_time
|
31
|
+
if target_time > Time.now
|
32
|
+
puts "Servers are still winding down the cache.\nThey will be done at #{target_time} #{((target_time.to_i - Time.now.to_i)/60.0).ceil} mins from now.\nContinue with deploy?"
|
33
|
+
raise "Servers still winding down." if STDIN.gets !~ /^y/i
|
34
|
+
else
|
35
|
+
puts "Wind Down completed at #{target_time}"
|
36
|
+
end
|
37
|
+
else
|
38
|
+
puts "No wind down in progress."
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
desc "Get info about the current state of the wind down."
|
43
|
+
task :info do
|
44
|
+
if target_time = get_wind_down_time
|
45
|
+
if target_time > Time.now
|
46
|
+
puts "Servers are winding down the cache.\nThey will be done at #{target_time} #{((target_time.to_i - Time.now.to_i)/60.0).ceil} mins from now."
|
47
|
+
else
|
48
|
+
puts "Wind Down completed at #{target_time}"
|
49
|
+
end
|
50
|
+
else
|
51
|
+
puts "No wind down in progress."
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
before "deploy:update_code", "cache:check_ready_to_deploy"
|
57
|
+
end
|
data/test/cache_busting_test.rb
CHANGED
@@ -5,7 +5,7 @@ class CacheBustingTest < IntegrationTest
|
|
5
5
|
|
6
6
|
should "forward the request to the app" do
|
7
7
|
given_env = nil
|
8
|
-
@app = Rack::CacheBuster.new(lambda { |env| given_env = env; [200, {}, []] })
|
8
|
+
@app = Rack::CacheBuster.new(lambda { |env| given_env = env; [200, {}, []] }, nil, Time.now)
|
9
9
|
get "/foooo"
|
10
10
|
assert_not_nil given_env
|
11
11
|
assert_equal given_env["PATH_INFO"], "/foooo"
|
@@ -14,7 +14,7 @@ class CacheBustingTest < IntegrationTest
|
|
14
14
|
should "forward the request to the app with digests removed" do
|
15
15
|
given_env = nil
|
16
16
|
|
17
|
-
@app = Rack::CacheBuster.new(lambda { |env| given_env = env; [200, {}, []] }, "foo")
|
17
|
+
@app = Rack::CacheBuster.new(lambda { |env| given_env = env; [200, {}, []] }, "foo", Time.now)
|
18
18
|
|
19
19
|
["If-None-Match", "If-Match", "If-Range"].each do |k|
|
20
20
|
header(k, "foo-#{DIGEST_OF_FOO}")
|
@@ -27,21 +27,28 @@ class CacheBustingTest < IntegrationTest
|
|
27
27
|
end
|
28
28
|
end
|
29
29
|
|
30
|
-
should "set
|
31
|
-
@app = Rack::CacheBuster.new(CACHEY_APP)
|
30
|
+
should "set max-age to 0 if we should already have expired" do
|
31
|
+
@app = Rack::CacheBuster.new(CACHEY_APP, nil, Time.now - 100)
|
32
32
|
get "/foooo"
|
33
|
-
assert_equal "
|
33
|
+
assert_equal "max-age=0, public", last_response["Cache-Control"]
|
34
|
+
end
|
35
|
+
|
36
|
+
should "set max-age to 0 if we are at expirey time" do
|
37
|
+
@app = Rack::CacheBuster.new(CACHEY_APP, nil, Time.now)
|
38
|
+
get "/foooo"
|
39
|
+
assert_equal "max-age=0, public", last_response["Cache-Control"]
|
40
|
+
end
|
41
|
+
|
42
|
+
should "set max-age to 10 if we have 10s left" do
|
43
|
+
@app = Rack::CacheBuster.new(CACHEY_APP, nil, Time.now + 10)
|
44
|
+
get "/foooo"
|
45
|
+
assert_equal "max-age=10, public", last_response["Cache-Control"]
|
34
46
|
end
|
35
47
|
|
36
48
|
should 'append version to etag of response' do
|
37
|
-
@app = Rack::CacheBuster.new(CACHEY_APP, "foo")
|
49
|
+
@app = Rack::CacheBuster.new(CACHEY_APP, "foo", Time.now)
|
38
50
|
get "/"
|
39
51
|
assert_equal %Q{"an-etag-#{DIGEST_OF_FOO}"}, last_response["ETag"]
|
40
52
|
end
|
41
53
|
|
42
|
-
should "leave the ETag alone if no key is given" do
|
43
|
-
@app = Rack::CacheBuster.new(CACHEY_APP)
|
44
|
-
get "/foooo"
|
45
|
-
assert_equal %Q{"an-etag"}, last_response["ETag"]
|
46
|
-
end
|
47
54
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rack-cache-buster
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Tom Lea
|
@@ -9,7 +9,7 @@ autorequire:
|
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
11
|
|
12
|
-
date: 2009-10-
|
12
|
+
date: 2009-10-23 00:00:00 +01:00
|
13
13
|
default_executable:
|
14
14
|
dependencies: []
|
15
15
|
|
@@ -23,10 +23,10 @@ extra_rdoc_files:
|
|
23
23
|
- README.markdown
|
24
24
|
files:
|
25
25
|
- README.markdown
|
26
|
-
-
|
26
|
+
- recipes/rack_cache_buster_recipes.rb
|
27
27
|
- test/cache_busting_test.rb
|
28
28
|
- test/test_helper.rb
|
29
|
-
- lib/rack/cache_buster/
|
29
|
+
- lib/rack/cache_buster/cache_control_header.rb
|
30
30
|
- lib/rack/cache_buster.rb
|
31
31
|
has_rdoc: true
|
32
32
|
homepage: http://tomlea.co.uk/
|
@@ -37,6 +37,7 @@ rdoc_options:
|
|
37
37
|
- --main
|
38
38
|
- README.markdown
|
39
39
|
require_paths:
|
40
|
+
- recipes
|
40
41
|
- lib
|
41
42
|
required_ruby_version: !ruby/object:Gem::Requirement
|
42
43
|
requirements:
|
@@ -1,13 +0,0 @@
|
|
1
|
-
require "rack/cache_buster"
|
2
|
-
|
3
|
-
class Rack::CacheBuster::Auto < Rack::CacheBuster
|
4
|
-
def initialize(app, key_filename, enabled_filename)
|
5
|
-
@enabled = File.exists?(enabled_filename)
|
6
|
-
key = File.exists?(key_filename) && File.open(key_filename).gets.chomp
|
7
|
-
super(app, key)
|
8
|
-
end
|
9
|
-
|
10
|
-
def bust_cache!(headers)
|
11
|
-
@enabled ? super(headers) : headers
|
12
|
-
end
|
13
|
-
end
|
data/test/auto_test.rb
DELETED
@@ -1,70 +0,0 @@
|
|
1
|
-
#FOO
|
2
|
-
require "test_helper"
|
3
|
-
|
4
|
-
class CacheBusterAutoTest < IntegrationTest
|
5
|
-
DIGEST_OF_HASH_FOO=Digest::MD5.hexdigest("#FOO")
|
6
|
-
|
7
|
-
context "with no key files" do
|
8
|
-
setup do
|
9
|
-
@app = Rack::CacheBuster::Auto.new(CACHEY_APP, "/no-file-here.txt", "/no-file-here.txt")
|
10
|
-
end
|
11
|
-
|
12
|
-
should "not set cache-control to no-cache" do
|
13
|
-
get "/foooo"
|
14
|
-
assert_not_equal "no-cache", last_response["Cache-Control"]
|
15
|
-
end
|
16
|
-
|
17
|
-
should "leave the ETag alone" do
|
18
|
-
get "/foooo"
|
19
|
-
assert_equal %Q{"an-etag"}, last_response["ETag"]
|
20
|
-
end
|
21
|
-
end
|
22
|
-
|
23
|
-
context "with a key file" do
|
24
|
-
setup do
|
25
|
-
@app = Rack::CacheBuster::Auto.new(CACHEY_APP, __FILE__, "/no-file-here.txt")
|
26
|
-
end
|
27
|
-
|
28
|
-
should "still not set cache-control to no-cache" do
|
29
|
-
get "/foooo"
|
30
|
-
assert_not_equal "no-cache", last_response["Cache-Control"]
|
31
|
-
end
|
32
|
-
|
33
|
-
should "fiddle with the ETag" do
|
34
|
-
get "/foooo"
|
35
|
-
assert_equal %Q{"an-etag-#{DIGEST_OF_HASH_FOO}"}, last_response["ETag"]
|
36
|
-
end
|
37
|
-
end
|
38
|
-
|
39
|
-
context "with an enabled file" do
|
40
|
-
setup do
|
41
|
-
@app = Rack::CacheBuster::Auto.new(CACHEY_APP, "/no-file-here.txt", __FILE__)
|
42
|
-
end
|
43
|
-
|
44
|
-
should "set cache-control to no-cache" do
|
45
|
-
get "/foooo"
|
46
|
-
assert_equal "no-cache", last_response["Cache-Control"]
|
47
|
-
end
|
48
|
-
|
49
|
-
should "leave the ETag alone" do
|
50
|
-
get "/foooo"
|
51
|
-
assert_equal %Q{"an-etag"}, last_response["ETag"]
|
52
|
-
end
|
53
|
-
end
|
54
|
-
|
55
|
-
context "with an enabled file and a key file" do
|
56
|
-
setup do
|
57
|
-
@app = Rack::CacheBuster::Auto.new(CACHEY_APP, __FILE__, __FILE__)
|
58
|
-
end
|
59
|
-
|
60
|
-
should "set cache-control to no-cache" do
|
61
|
-
get "/foooo"
|
62
|
-
assert_equal "no-cache", last_response["Cache-Control"]
|
63
|
-
end
|
64
|
-
|
65
|
-
should "fiddle with the ETag" do
|
66
|
-
get "/foooo"
|
67
|
-
assert_equal %Q{"an-etag-#{DIGEST_OF_HASH_FOO}"}, last_response["ETag"]
|
68
|
-
end
|
69
|
-
end
|
70
|
-
end
|