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.
@@ -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