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.
@@ -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/sync'
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}, {Sync}, and {I18nBackend} and puts them together.
161
+ # This creates the {Client}, {Cache}, and {I18nBackend} and puts them together.
160
162
  #
161
- # When {#test?} returns +false+, the sync will be started.
163
+ # When {#test?} returns +false+, the poller will be started.
162
164
  def apply
163
165
  client = Client.new(to_hash)
164
- sync = Sync.new(client, to_hash)
165
- I18n.backend = I18nBackend.new(sync)
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.sync = sync
168
- middleware.use(RequestSync, :sync => sync) if middleware && development?
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
- sync.start unless test?
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 {Sync}. The application will
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 sync [Sync] must act like a hash, returning and accept blurbs by key.
18
- def initialize(sync)
19
- @sync = sync
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
- sync_locales = sync.keys.map { |key| key.split('.').first }
38
- (sync_locales + super).uniq.map { |locale| locale.to_sym }
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 sync.
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 = sync[key_with_locale] || super
60
- sync[key_with_locale] = "" if content.nil?
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
- sync[key] = data.to_str
71
+ cache[key] = data.to_str
72
72
  end
73
73
  end
74
74
 
75
75
  def load_translations(*filenames)
76
76
  super
77
- sync.wait_for_download
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
- sync[key] = content.to_str
85
+ cache[key] = content.to_str
86
86
  end
87
87
  content
88
88
  end
89
89
 
90
- attr_reader :sync
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 [Sync] :sync agent that should be flushed after each request
8
+ # @option options [Cache] :cache agent that should be flushed after each request
9
9
  def initialize(app, options)
10
10
  @app = app
11
- @sync = options[:sync]
11
+ @cache = options[:cache]
12
12
  end
13
13
 
14
- # Invokes the upstream Rack application and flushes the sync after each
14
+ # Invokes the upstream Rack application and flushes the cache after each
15
15
  # request.
16
16
  def call(env)
17
- @sync.download
17
+ @cache.download
18
18
  response = @app.call(env)
19
- @sync.flush
19
+ @cache.flush
20
20
  response
21
21
  end
22
22
  end
@@ -1,6 +1,6 @@
1
1
  module CopycopterClient
2
2
  # Client version
3
- VERSION = "1.0.2"
3
+ VERSION = "1.0.3"
4
4
 
5
5
  # API version being used to communicate with the server
6
6
  API_VERSION = "2.0"
@@ -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 [Sync] instance used to synchronize changes.
20
+ # @return [Cache] instance used to synchronize changes.
21
21
  # This is set when {.configure} is called.
22
- attr_accessor :sync
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
- # This is called from Unicorn worker processes.
33
- def self.start_sync
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
- sync.flush
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
+