copycopter_client 1.0.2 → 1.0.3
Sign up to get free protection for your applications and to get access to all the features.
- data/lib/copycopter_client/cache.rb +106 -0
- data/lib/copycopter_client/configuration.rb +12 -8
- data/lib/copycopter_client/errors.rb +1 -1
- data/lib/copycopter_client/i18n_backend.rb +12 -12
- data/lib/copycopter_client/poller.rb +44 -0
- data/lib/copycopter_client/process_guard.rb +92 -0
- data/lib/copycopter_client/request_sync.rb +5 -5
- data/lib/copycopter_client/version.rb +1 -1
- data/lib/copycopter_client.rb +9 -7
- data/spec/copycopter_client/cache_spec.rb +207 -0
- data/spec/copycopter_client/configuration_spec.rb +23 -10
- data/spec/copycopter_client/i18n_backend_spec.rb +22 -22
- data/spec/copycopter_client/poller_spec.rb +110 -0
- data/spec/copycopter_client/process_guard_spec.rb +118 -0
- data/spec/copycopter_client/request_sync_spec.rb +5 -5
- data/spec/support/fake_client.rb +7 -1
- data/spec/support/fake_resque_job.rb +12 -5
- data/spec/support/writing_cache.rb +17 -0
- metadata +18 -13
- data/lib/copycopter_client/sync.rb +0 -182
- data/spec/copycopter_client/sync_spec.rb +0 -415
@@ -0,0 +1,106 @@
|
|
1
|
+
require 'thread'
|
2
|
+
require 'copycopter_client/client'
|
3
|
+
|
4
|
+
module CopycopterClient
|
5
|
+
# Manages synchronization of copy between {I18nBackend} and {Client}. Acts
|
6
|
+
# like a Hash. Applications using the client will not need to interact with
|
7
|
+
# this class directly.
|
8
|
+
#
|
9
|
+
# Responsible for locking down access to data used by both threads.
|
10
|
+
class Cache
|
11
|
+
# Usually instantiated when {Configuration#apply} is invoked.
|
12
|
+
# @param client [Client] the client used to fetch and upload data
|
13
|
+
# @param options [Hash]
|
14
|
+
# @option options [Logger] :logger where errors should be logged
|
15
|
+
def initialize(client, options)
|
16
|
+
@client = client
|
17
|
+
@blurbs = {}
|
18
|
+
@queued = {}
|
19
|
+
@mutex = Mutex.new
|
20
|
+
@logger = options[:logger]
|
21
|
+
@started = false
|
22
|
+
@downloaded = false
|
23
|
+
end
|
24
|
+
|
25
|
+
# Returns content for the given blurb.
|
26
|
+
# @param key [String] the key of the desired blurb
|
27
|
+
# @return [String] the contents of the blurb
|
28
|
+
def [](key)
|
29
|
+
lock { @blurbs[key] }
|
30
|
+
end
|
31
|
+
|
32
|
+
# Sets content for the given blurb. The content will be pushed to the
|
33
|
+
# server on the next flush.
|
34
|
+
# @param key [String] the key of the blurb to update
|
35
|
+
# @param value [String] the new contents of the blurb
|
36
|
+
def []=(key, value)
|
37
|
+
lock { @queued[key] = value }
|
38
|
+
end
|
39
|
+
|
40
|
+
# Keys for all blurbs stored on the server.
|
41
|
+
# @return [Array<String>] keys
|
42
|
+
def keys
|
43
|
+
lock { @blurbs.keys }
|
44
|
+
end
|
45
|
+
|
46
|
+
# Waits until the first download has finished.
|
47
|
+
def wait_for_download
|
48
|
+
if pending?
|
49
|
+
logger.info("Waiting for first download")
|
50
|
+
logger.flush if logger.respond_to?(:flush)
|
51
|
+
while pending?
|
52
|
+
sleep(0.1)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def flush
|
58
|
+
with_queued_changes do |queued|
|
59
|
+
client.upload(queued)
|
60
|
+
end
|
61
|
+
rescue ConnectionError => error
|
62
|
+
logger.error(error.message)
|
63
|
+
end
|
64
|
+
|
65
|
+
def download
|
66
|
+
@started = true
|
67
|
+
client.download do |downloaded_blurbs|
|
68
|
+
downloaded_blurbs.reject! { |key, value| value == "" }
|
69
|
+
lock { @blurbs = downloaded_blurbs }
|
70
|
+
end
|
71
|
+
rescue ConnectionError => error
|
72
|
+
logger.error(error.message)
|
73
|
+
ensure
|
74
|
+
@downloaded = true
|
75
|
+
end
|
76
|
+
|
77
|
+
# Downloads and then flushes
|
78
|
+
def sync
|
79
|
+
download
|
80
|
+
flush
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
attr_reader :client, :logger
|
86
|
+
|
87
|
+
def with_queued_changes
|
88
|
+
changes_to_push = nil
|
89
|
+
lock do
|
90
|
+
unless @queued.empty?
|
91
|
+
changes_to_push = @queued
|
92
|
+
@queued = {}
|
93
|
+
end
|
94
|
+
end
|
95
|
+
yield(changes_to_push) if changes_to_push
|
96
|
+
end
|
97
|
+
|
98
|
+
def lock(&block)
|
99
|
+
@mutex.synchronize(&block)
|
100
|
+
end
|
101
|
+
|
102
|
+
def pending?
|
103
|
+
@started && !@downloaded
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -1,7 +1,9 @@
|
|
1
1
|
require 'logger'
|
2
2
|
require 'copycopter_client/i18n_backend'
|
3
3
|
require 'copycopter_client/client'
|
4
|
-
require 'copycopter_client/
|
4
|
+
require 'copycopter_client/cache'
|
5
|
+
require 'copycopter_client/process_guard'
|
6
|
+
require 'copycopter_client/poller'
|
5
7
|
require 'copycopter_client/prefixed_logger'
|
6
8
|
require 'copycopter_client/request_sync'
|
7
9
|
|
@@ -156,20 +158,22 @@ module CopycopterClient
|
|
156
158
|
#
|
157
159
|
# Called automatically when {CopycopterClient.configure} is called in the application.
|
158
160
|
#
|
159
|
-
# This creates the {Client}, {
|
161
|
+
# This creates the {Client}, {Cache}, and {I18nBackend} and puts them together.
|
160
162
|
#
|
161
|
-
# When {#test?} returns +false+, the
|
163
|
+
# When {#test?} returns +false+, the poller will be started.
|
162
164
|
def apply
|
163
165
|
client = Client.new(to_hash)
|
164
|
-
|
165
|
-
|
166
|
+
cache = Cache.new(client, to_hash)
|
167
|
+
poller = Poller.new(cache, to_hash)
|
168
|
+
process_guard = ProcessGuard.new(cache, poller, to_hash)
|
169
|
+
I18n.backend = I18nBackend.new(cache)
|
166
170
|
CopycopterClient.client = client
|
167
|
-
CopycopterClient.
|
168
|
-
middleware.use(RequestSync, :
|
171
|
+
CopycopterClient.cache = cache
|
172
|
+
middleware.use(RequestSync, :cache => cache) if middleware && development?
|
169
173
|
@applied = true
|
170
174
|
logger.info("Client #{VERSION} ready")
|
171
175
|
logger.info("Environment Info: #{environment_info}")
|
172
|
-
|
176
|
+
process_guard.start unless test?
|
173
177
|
end
|
174
178
|
|
175
179
|
def port
|
@@ -1,6 +1,6 @@
|
|
1
1
|
module CopycopterClient
|
2
2
|
# Raised when an error occurs while contacting the Copycopter server. This is
|
3
|
-
# raised by {Client} and generally rescued by {
|
3
|
+
# raised by {Client} and generally rescued by {Cache}. The application will
|
4
4
|
# not encounter this error. Polling will continue even if this error is raised.
|
5
5
|
class ConnectionError < StandardError
|
6
6
|
end
|
@@ -14,9 +14,9 @@ module CopycopterClient
|
|
14
14
|
include I18n::Backend::Simple::Implementation
|
15
15
|
|
16
16
|
# Usually instantiated when {Configuration#apply} is invoked.
|
17
|
-
# @param
|
18
|
-
def initialize(
|
19
|
-
@
|
17
|
+
# @param cache [Cache] must act like a hash, returning and accept blurbs by key.
|
18
|
+
def initialize(cache)
|
19
|
+
@cache = cache
|
20
20
|
end
|
21
21
|
|
22
22
|
# Translates the given local and key. See the I18n API documentation for details.
|
@@ -34,14 +34,14 @@ module CopycopterClient
|
|
34
34
|
# Returns locales availabile for this Copycopter project.
|
35
35
|
# @return [Array<String>] available locales
|
36
36
|
def available_locales
|
37
|
-
|
38
|
-
(
|
37
|
+
cached_locales = cache.keys.map { |key| key.split('.').first }
|
38
|
+
(cached_locales + super).uniq.map { |locale| locale.to_sym }
|
39
39
|
end
|
40
40
|
|
41
41
|
# Stores the given translations.
|
42
42
|
#
|
43
43
|
# Updates will be visible in the current process immediately, and will
|
44
|
-
# propagate to Copycopter during the next
|
44
|
+
# propagate to Copycopter during the next flush.
|
45
45
|
#
|
46
46
|
# @param [String] locale the locale (ie "en") to store translations for
|
47
47
|
# @param [Hash] data nested key-value pairs to be added as blurbs
|
@@ -56,8 +56,8 @@ module CopycopterClient
|
|
56
56
|
def lookup(locale, key, scope = [], options = {})
|
57
57
|
parts = I18n.normalize_keys(locale, key, scope, options[:separator])
|
58
58
|
key_with_locale = parts.join('.')
|
59
|
-
content =
|
60
|
-
|
59
|
+
content = cache[key_with_locale] || super
|
60
|
+
cache[key_with_locale] = "" if content.nil?
|
61
61
|
content
|
62
62
|
end
|
63
63
|
|
@@ -68,13 +68,13 @@ module CopycopterClient
|
|
68
68
|
end
|
69
69
|
elsif data.respond_to?(:to_str)
|
70
70
|
key = ([locale] + scope).join('.')
|
71
|
-
|
71
|
+
cache[key] = data.to_str
|
72
72
|
end
|
73
73
|
end
|
74
74
|
|
75
75
|
def load_translations(*filenames)
|
76
76
|
super
|
77
|
-
|
77
|
+
cache.wait_for_download
|
78
78
|
end
|
79
79
|
|
80
80
|
def default(locale, object, subject, options = {})
|
@@ -82,11 +82,11 @@ module CopycopterClient
|
|
82
82
|
if content.respond_to?(:to_str)
|
83
83
|
parts = I18n.normalize_keys(locale, object, options[:scope], options[:separator])
|
84
84
|
key = parts.join('.')
|
85
|
-
|
85
|
+
cache[key] = content.to_str
|
86
86
|
end
|
87
87
|
content
|
88
88
|
end
|
89
89
|
|
90
|
-
attr_reader :
|
90
|
+
attr_reader :cache
|
91
91
|
end
|
92
92
|
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'thread'
|
2
|
+
require 'copycopter_client/cache'
|
3
|
+
|
4
|
+
module CopycopterClient
|
5
|
+
# Starts a background thread that continually resynchronizes with the remote
|
6
|
+
# server using the given {Cache} after a set delay.
|
7
|
+
class Poller
|
8
|
+
# @param options [Hash]
|
9
|
+
# @option options [Logger] :logger where errors should be logged
|
10
|
+
# @option options [Fixnum] :polling_delay how long to wait in between requests
|
11
|
+
def initialize(cache, options)
|
12
|
+
@cache = cache
|
13
|
+
@polling_delay = options[:polling_delay]
|
14
|
+
@logger = options[:logger]
|
15
|
+
@stop = false
|
16
|
+
end
|
17
|
+
|
18
|
+
def start
|
19
|
+
Thread.new { poll } or logger.error("Couldn't start poller thread")
|
20
|
+
end
|
21
|
+
|
22
|
+
def stop
|
23
|
+
@stop = true
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
attr_reader :cache, :logger, :polling_delay
|
29
|
+
|
30
|
+
def poll
|
31
|
+
until @stop
|
32
|
+
cache.sync
|
33
|
+
logger.flush if logger.respond_to?(:flush)
|
34
|
+
delay
|
35
|
+
end
|
36
|
+
rescue InvalidApiKey => error
|
37
|
+
logger.error(error.message)
|
38
|
+
end
|
39
|
+
|
40
|
+
def delay
|
41
|
+
sleep(polling_delay)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
module CopycopterClient
|
2
|
+
# Starts the poller from a worker process, or register hooks for a spawner
|
3
|
+
# process (such as in Unicorn or Passenger). Also registers hooks for exiting
|
4
|
+
# processes and completing background jobs. Applications using the client
|
5
|
+
# will not need to interact with this class directly.
|
6
|
+
class ProcessGuard
|
7
|
+
# @param options [Hash]
|
8
|
+
# @option options [Logger] :logger where errors should be logged
|
9
|
+
def initialize(cache, poller, options)
|
10
|
+
@cache = cache
|
11
|
+
@poller = poller
|
12
|
+
@logger = options[:logger]
|
13
|
+
end
|
14
|
+
|
15
|
+
# Starts the poller or registers hooks
|
16
|
+
def start
|
17
|
+
if spawner?
|
18
|
+
register_spawn_hooks
|
19
|
+
else
|
20
|
+
register_exit_hooks
|
21
|
+
register_job_hooks
|
22
|
+
start_polling
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def start_polling
|
29
|
+
@poller.start
|
30
|
+
end
|
31
|
+
|
32
|
+
def spawner?
|
33
|
+
passenger_spawner? || unicorn_spawner?
|
34
|
+
end
|
35
|
+
|
36
|
+
def passenger_spawner?
|
37
|
+
$0.include?("ApplicationSpawner")
|
38
|
+
end
|
39
|
+
|
40
|
+
def unicorn_spawner?
|
41
|
+
$0.include?("unicorn") && !caller.any? { |line| line.include?("worker_loop") }
|
42
|
+
end
|
43
|
+
|
44
|
+
def register_spawn_hooks
|
45
|
+
if defined?(PhusionPassenger)
|
46
|
+
register_passenger_hook
|
47
|
+
elsif defined?(Unicorn::HttpServer)
|
48
|
+
register_unicorn_hook
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def register_passenger_hook
|
53
|
+
@logger.info("Registered Phusion Passenger fork hook")
|
54
|
+
PhusionPassenger.on_event(:starting_worker_process) do |forked|
|
55
|
+
start_polling
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def register_unicorn_hook
|
60
|
+
@logger.info("Registered Unicorn fork hook")
|
61
|
+
poller = @poller
|
62
|
+
Unicorn::HttpServer.class_eval do
|
63
|
+
alias_method :worker_loop_without_copycopter, :worker_loop
|
64
|
+
define_method :worker_loop do |worker|
|
65
|
+
poller.start
|
66
|
+
worker_loop_without_copycopter(worker)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def register_exit_hooks
|
72
|
+
at_exit do
|
73
|
+
@cache.flush
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def register_job_hooks
|
78
|
+
if defined?(Resque::Job)
|
79
|
+
@logger.info("Registered Resque after_perform hook")
|
80
|
+
cache = @cache
|
81
|
+
Resque::Job.class_eval do
|
82
|
+
alias_method :perform_without_copycopter, :perform
|
83
|
+
define_method :perform do
|
84
|
+
job_was_performed = perform_without_copycopter
|
85
|
+
cache.flush
|
86
|
+
job_was_performed
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -5,18 +5,18 @@ module CopycopterClient
|
|
5
5
|
class RequestSync
|
6
6
|
# @param app [Rack] the upstream app into whose responses to inject the editor
|
7
7
|
# @param options [Hash]
|
8
|
-
# @option options [
|
8
|
+
# @option options [Cache] :cache agent that should be flushed after each request
|
9
9
|
def initialize(app, options)
|
10
10
|
@app = app
|
11
|
-
@
|
11
|
+
@cache = options[:cache]
|
12
12
|
end
|
13
13
|
|
14
|
-
# Invokes the upstream Rack application and flushes the
|
14
|
+
# Invokes the upstream Rack application and flushes the cache after each
|
15
15
|
# request.
|
16
16
|
def call(env)
|
17
|
-
@
|
17
|
+
@cache.download
|
18
18
|
response = @app.call(env)
|
19
|
-
@
|
19
|
+
@cache.flush
|
20
20
|
response
|
21
21
|
end
|
22
22
|
end
|
data/lib/copycopter_client.rb
CHANGED
@@ -17,9 +17,13 @@ module CopycopterClient
|
|
17
17
|
# configuration options. Usually set when {.configure} is called.
|
18
18
|
attr_accessor :configuration
|
19
19
|
|
20
|
-
# @return [
|
20
|
+
# @return [Cache] instance used to synchronize changes.
|
21
21
|
# This is set when {.configure} is called.
|
22
|
-
attr_accessor :
|
22
|
+
attr_accessor :cache
|
23
|
+
|
24
|
+
# @return [Poller] instance used to poll for changes.
|
25
|
+
# This is set when {.configure} is called.
|
26
|
+
attr_accessor :poller
|
23
27
|
end
|
24
28
|
|
25
29
|
# Issues a new deploy, marking all draft blurbs as published.
|
@@ -29,15 +33,13 @@ module CopycopterClient
|
|
29
33
|
end
|
30
34
|
|
31
35
|
# Starts the polling process.
|
32
|
-
|
33
|
-
|
34
|
-
sync.start
|
36
|
+
def self.start_poller
|
37
|
+
poller.start
|
35
38
|
end
|
36
39
|
|
37
40
|
# Flush queued changed synchronously
|
38
|
-
# This is called from the Resque after perform "hook"
|
39
41
|
def self.flush
|
40
|
-
|
42
|
+
cache.flush
|
41
43
|
end
|
42
44
|
|
43
45
|
# Call this method to modify defaults in your initializers.
|
@@ -0,0 +1,207 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe CopycopterClient::Cache do
|
4
|
+
let(:client) { FakeClient.new }
|
5
|
+
|
6
|
+
def build_cache(config = {})
|
7
|
+
config[:logger] ||= FakeLogger.new
|
8
|
+
default_config = CopycopterClient::Configuration.new.to_hash
|
9
|
+
CopycopterClient::Cache.new(client, default_config.update(config))
|
10
|
+
end
|
11
|
+
|
12
|
+
it "provides access to downloaded data" do
|
13
|
+
client['en.test.key'] = 'expected'
|
14
|
+
client['en.test.other_key'] = 'expected'
|
15
|
+
|
16
|
+
cache = build_cache
|
17
|
+
|
18
|
+
cache.download
|
19
|
+
|
20
|
+
cache['en.test.key'].should == 'expected'
|
21
|
+
cache.keys.should =~ %w(en.test.key en.test.other_key)
|
22
|
+
end
|
23
|
+
|
24
|
+
it "doesn't upload without changes" do
|
25
|
+
cache = build_cache
|
26
|
+
cache.flush
|
27
|
+
client.should_not be_uploaded
|
28
|
+
end
|
29
|
+
|
30
|
+
it "uploads changes when flushed" do
|
31
|
+
cache = build_cache
|
32
|
+
cache['test.key'] = 'test value'
|
33
|
+
|
34
|
+
cache.flush
|
35
|
+
|
36
|
+
client.uploaded.should == { 'test.key' => 'test value' }
|
37
|
+
end
|
38
|
+
|
39
|
+
it "downloads changes" do
|
40
|
+
client['test.key'] = 'test value'
|
41
|
+
cache = build_cache
|
42
|
+
|
43
|
+
cache.download
|
44
|
+
|
45
|
+
cache['test.key'].should == 'test value'
|
46
|
+
end
|
47
|
+
|
48
|
+
it "downloads and uploads when synced" do
|
49
|
+
cache = build_cache
|
50
|
+
client['test.key'] = 'test value'
|
51
|
+
cache['other.key'] = 'other value'
|
52
|
+
|
53
|
+
cache.sync
|
54
|
+
|
55
|
+
client.uploaded.should == { 'other.key' => 'other value' }
|
56
|
+
cache['test.key'].should == 'test value'
|
57
|
+
end
|
58
|
+
|
59
|
+
it "handles connection errors when flushing" do
|
60
|
+
failure = "server is napping"
|
61
|
+
logger = FakeLogger.new
|
62
|
+
client.stubs(:upload).raises(CopycopterClient::ConnectionError.new(failure))
|
63
|
+
cache = build_cache(:logger => logger)
|
64
|
+
cache['upload.key'] = 'upload'
|
65
|
+
|
66
|
+
cache.flush
|
67
|
+
|
68
|
+
logger.should have_entry(:error, failure)
|
69
|
+
end
|
70
|
+
|
71
|
+
it "handles connection errors when downloading" do
|
72
|
+
failure = "server is napping"
|
73
|
+
logger = FakeLogger.new
|
74
|
+
client.stubs(:download).raises(CopycopterClient::ConnectionError.new(failure))
|
75
|
+
cache = build_cache(:logger => logger)
|
76
|
+
|
77
|
+
cache.download
|
78
|
+
|
79
|
+
logger.should have_entry(:error, failure)
|
80
|
+
end
|
81
|
+
|
82
|
+
it "blocks until the first download is complete" do
|
83
|
+
logger = FakeLogger.new
|
84
|
+
logger.stubs(:flush)
|
85
|
+
client.delay = 0.5
|
86
|
+
cache = build_cache(:logger => logger)
|
87
|
+
|
88
|
+
Thread.new { cache.download }
|
89
|
+
|
90
|
+
finished = false
|
91
|
+
Thread.new do
|
92
|
+
cache.wait_for_download
|
93
|
+
finished = true
|
94
|
+
end
|
95
|
+
|
96
|
+
sleep(1)
|
97
|
+
|
98
|
+
finished.should == true
|
99
|
+
logger.should have_entry(:info, "Waiting for first download")
|
100
|
+
logger.should have_received(:flush)
|
101
|
+
end
|
102
|
+
|
103
|
+
it "doesn't block if the first download fails" do
|
104
|
+
client.delay = 0.5
|
105
|
+
client.error = StandardError.new("Failure")
|
106
|
+
cache = build_cache
|
107
|
+
|
108
|
+
Thread.new { cache.download }
|
109
|
+
|
110
|
+
finished = false
|
111
|
+
Thread.new do
|
112
|
+
cache.wait_for_download
|
113
|
+
finished = true
|
114
|
+
end
|
115
|
+
|
116
|
+
sleep(1)
|
117
|
+
|
118
|
+
expect { cache.download }.to raise_error(StandardError, "Failure")
|
119
|
+
finished.should == true
|
120
|
+
end
|
121
|
+
|
122
|
+
it "doesn't block before downloading" do
|
123
|
+
logger = FakeLogger.new
|
124
|
+
cache = build_cache(:logger => logger)
|
125
|
+
|
126
|
+
finished = false
|
127
|
+
Thread.new do
|
128
|
+
cache.wait_for_download
|
129
|
+
finished = true
|
130
|
+
end
|
131
|
+
|
132
|
+
sleep(1)
|
133
|
+
|
134
|
+
finished.should == true
|
135
|
+
logger.should_not have_entry(:info, "Waiting for first download")
|
136
|
+
end
|
137
|
+
|
138
|
+
it "doesn't return blank copy" do
|
139
|
+
client['en.test.key'] = ''
|
140
|
+
cache = build_cache
|
141
|
+
|
142
|
+
cache.download
|
143
|
+
|
144
|
+
cache['en.test.key'].should be_nil
|
145
|
+
end
|
146
|
+
|
147
|
+
describe "given locked mutex" do
|
148
|
+
RSpec::Matchers.define :finish_after_unlocking do |mutex|
|
149
|
+
match do |thread|
|
150
|
+
sleep(0.1)
|
151
|
+
|
152
|
+
if thread.status === false
|
153
|
+
violated("finished before unlocking")
|
154
|
+
else
|
155
|
+
mutex.unlock
|
156
|
+
sleep(0.1)
|
157
|
+
|
158
|
+
if thread.status === false
|
159
|
+
true
|
160
|
+
else
|
161
|
+
violated("still running after unlocking")
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def violated(failure)
|
167
|
+
@failure_message = failure
|
168
|
+
false
|
169
|
+
end
|
170
|
+
|
171
|
+
failure_message_for_should do
|
172
|
+
@failure_message
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
let(:mutex) { Mutex.new }
|
177
|
+
let(:cache) { build_cache }
|
178
|
+
|
179
|
+
before do
|
180
|
+
mutex.lock
|
181
|
+
Mutex.stubs(:new => mutex)
|
182
|
+
end
|
183
|
+
|
184
|
+
it "synchronizes read access to keys between threads" do
|
185
|
+
Thread.new { cache['test.key'] }.should finish_after_unlocking(mutex)
|
186
|
+
end
|
187
|
+
|
188
|
+
it "synchronizes read access to the key list between threads" do
|
189
|
+
Thread.new { cache.keys }.should finish_after_unlocking(mutex)
|
190
|
+
end
|
191
|
+
|
192
|
+
it "synchronizes write access to keys between threads" do
|
193
|
+
Thread.new { cache['test.key'] = 'value' }.should finish_after_unlocking(mutex)
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
it "flushes from the top level" do
|
198
|
+
cache = build_cache
|
199
|
+
CopycopterClient.cache = cache
|
200
|
+
cache.stubs(:flush)
|
201
|
+
|
202
|
+
CopycopterClient.flush
|
203
|
+
|
204
|
+
cache.should have_received(:flush)
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|