copycopter_client 1.0.2 → 1.0.3

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