rack-cache-buster 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
- Busts the cache when enabled file exists, salts the ETags when key_file exists.
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
- ### Setup:
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
- During this period all caching will be disabled. If you respect If-Modified (using `stale?` in rails for example), these will still be respected.
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
- Once all the caches should have expired you can deploy as normal.
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.
@@ -2,53 +2,58 @@ require 'digest/md5'
2
2
 
3
3
  module Rack
4
4
  class CacheBuster
5
- autoload :Auto, "rack/cache_buster/auto"
6
-
7
- def initialize(app, key = nil)
8
- @app = app
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
- env = env.dup
17
- unpatch_etag!(env) if key
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 bust_cache!(headers)
39
- headers[CacheControl] = "no-cache"
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 unpatch_etag!(headers)
43
- ETAGGY_HEADERS.each do |k|
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 patch_etag!(headers)
49
- stripped_tag = headers[ETag].to_s.gsub(QUOTE_STRIPPER, "")
50
- return if stripped_tag.empty?
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
@@ -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 cache-control to no-cache" do
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 "no-cache", last_response["Cache-Control"]
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.1.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-21 00:00:00 +01:00
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
- - test/auto_test.rb
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/auto.rb
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
@@ -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