slang 0.34.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.
@@ -0,0 +1,49 @@
1
+ module Slang
2
+ module Updater
3
+
4
+ module HTTPHelpers
5
+ CHILLAX_RETRY = 1
6
+ GONE_RETRY = 7200
7
+ TEMPORARY_RETRY = 60
8
+ UNEXPECTED_RETRY = 600
9
+ UNKNOWN_RETRY = 120
10
+
11
+ def get(uri, add_headers={})
12
+ request = Net::HTTP::Get.new(uri)
13
+ perform_request(request, add_headers)
14
+ end
15
+
16
+ def post(uri, body, add_headers={})
17
+ request = Net::HTTP::Post.new(uri)
18
+ request.body = body
19
+ perform_request(request, add_headers)
20
+ end
21
+
22
+ def perform_request(request, add_headers)
23
+ add_headers["User-Agent"] = Slang::USER_AGENT
24
+ add_headers["Accept-Encoding"] = "gzip" # Akamai doesn't handle Ruby's default Accept-Encoding with q-values
25
+ add_headers.each { |name, value| request[name] = value }
26
+ begin
27
+ uri = request.uri
28
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: (uri.scheme == "https")) do |http|
29
+ http.request(request)
30
+ end
31
+ response.body = gunzip(response.body) if response["Content-Encoding"] == "gzip" # must uncompress ourselves
32
+ response
33
+ rescue SystemCallError => e
34
+ raise NetworkError.new(TEMPORARY_RETRY), e.message
35
+ end
36
+ end
37
+
38
+ def gunzip(data)
39
+ io = StringIO.new(data)
40
+ gz = Zlib::GzipReader.new(io)
41
+ uncompressed = gz.read
42
+ gz.close
43
+ uncompressed
44
+ end
45
+
46
+ end # module
47
+
48
+ end
49
+ end
@@ -0,0 +1,92 @@
1
+ require "slang/updater/http_helpers"
2
+ require "slang/updater/squelchable"
3
+ module Slang
4
+ module Updater
5
+
6
+ class KeyReporter
7
+ include HTTPHelpers
8
+ include Squelchable
9
+
10
+ attr_reader :uri
11
+ attr_reader :dev_key
12
+ attr_reader :report_interval
13
+
14
+ # Create a key reporter.
15
+ #
16
+ # @param [String] key reporter endpoint
17
+ # @param [String] developer key
18
+ # @param [Numeric] report interval
19
+ #
20
+ def initialize(url, dev_key, report_interval=10)
21
+ @uri = URI(url)
22
+ @dev_key = dev_key
23
+ @report_interval = report_interval
24
+ @pending = {}
25
+ @sent = Set.new
26
+ @lock = Mutex.new
27
+ end
28
+
29
+ # Report unknown key.
30
+ #
31
+ # @param [String] the unknown key
32
+ # @param [Hash] variable map passed (may be empty)
33
+ #
34
+ def unknown_key(key, variable_map)
35
+ var_names = variable_map.keys.sort!
36
+ key_info = var_names.unshift(key)
37
+ key_id = key_info.join("|").to_sym
38
+
39
+ pid = Process.pid
40
+ @lock.synchronize do
41
+ unless @pid == pid
42
+ @pid = pid
43
+ @thread = Thread.new { loop { run_loop } }
44
+ Slang.log_info("Started background key reporter thread (pid=#{pid}).")
45
+ end
46
+ unless @sent.include?(key_id)
47
+ @pending[key_id] = key_info
48
+ end
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def run_loop
55
+ squelch_thread_activity
56
+ begin
57
+ check_for_pending_keys
58
+ squelch!(report_interval)
59
+ rescue NetworkError => e
60
+ squelch!(e.retry_in, "network failure - #{e.message}")
61
+ rescue => e
62
+ squelch!(nil, "unexpected error - #{e.message}")
63
+ Slang.log_error(e.backtrace.join("\n")) unless SlangError === e
64
+ end
65
+ end
66
+
67
+ def check_for_pending_keys
68
+ report_pending = @lock.synchronize { @pending.dup }
69
+ return if report_pending.empty?
70
+
71
+ values = report_pending.values # key, [var1], ..., [varN]
72
+ Slang.log_info("Reporting new key(s): #{values.map { |k| k.first }.join(", ")}")
73
+ json = Oj.dump(values, mode: :compat)
74
+ response = post(uri, json, "Content-Type" => "application/json", "X-Slang-Dev-Key" => dev_key)
75
+ case response
76
+ when Net::HTTPSuccess #2xx
77
+ @lock.synchronize do
78
+ report_pending.each_key do |key_id|
79
+ @sent << key_id
80
+ @pending.delete(key_id)
81
+ end
82
+ end
83
+ when Net::HTTPServerError # 5xx
84
+ raise NetworkError.new(TEMPORARY_ERROR), "HTTP #{response.code}"
85
+ else
86
+ raise NetworkError.new(nil), "HTTP #{response.code}"
87
+ end
88
+ end
89
+
90
+ end # class
91
+ end
92
+ end
@@ -0,0 +1,218 @@
1
+ require "slang/updater/abstract"
2
+ module Slang
3
+ module Updater
4
+
5
+ # The production updater is responsible for periodically checking for new snapshots and activating them. State and
6
+ # snapshots are persisted to permanent storage in the Slang data directory. Multiple Ruby processes in the same
7
+ # Slang project can (and should) share the same Slang data directory to avoid redundant update checks.
8
+ #
9
+ class Production < Abstract
10
+
11
+ # Return the latest snapshot. Thread-safe.
12
+ #
13
+ def snapshot
14
+ pid = Process.pid
15
+ lock.synchronize do
16
+ unless @pid == pid
17
+ @pid = pid
18
+ @thread = Thread.new { loop { run_loop } }
19
+ Slang.log_info("Started background update thread (pid=#{pid}).")
20
+ end
21
+ @snapshot
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def run_loop
28
+ begin
29
+ sleep_for = shared_state.exclusive_lock do |state|
30
+ (state[:snapshot_id] == @snapshot.id) ? (state[:check_at].to_f - Time.now.to_f) : 0
31
+ end
32
+ if sleep_for > 0
33
+ sleep_for += rand
34
+ Slang.log_debug("Resting for a bit ~#{sleep_for.round(2)}s.")
35
+ sleep(sleep_for)
36
+ return
37
+ end
38
+ Slang.log_debug("Huh? I'm awake!")
39
+ check_for_new_snapshot
40
+ rescue => e
41
+ Slang.log_warn("Snapshot update failed: #{e.message}")
42
+ Slang.log_warn(e.backtrace.join("\n")) unless SlangError === e
43
+ shared_state.exclusive_lock do |state|
44
+ state.delete(:checking_pid) if state[:checking_pid] == @pid
45
+ state[:check_at] = Time.now.to_f + UNEXPECTED_RETRY
46
+ end
47
+ sleep(UNEXPECTED_RETRY)
48
+ end
49
+ end
50
+
51
+ def check_for_new_snapshot
52
+ action, arg = shared_state.exclusive_lock do |state|
53
+ if state[:snapshot_id] != @snapshot.id
54
+ [ :activate_snapshot, state[:snapshot_id] ]
55
+ elsif state[:checking_pid]
56
+ alive = Process.getpgid(state[:checking_pid]) rescue nil
57
+ if alive
58
+ [ :checker_alive, state[:checking_pid] ]
59
+ else
60
+ state[:checking_pid] = @pid
61
+ [ :checker_dead, state[:modified_at] ]
62
+ end
63
+ else
64
+ state[:checking_pid] = @pid
65
+ [ :check, state[:modified_at] ]
66
+ end
67
+ end
68
+
69
+ case action
70
+ when :activate_snapshot
71
+ Slang.log_debug("Sweet. Found new snapshot.")
72
+ snapshot_path = data_file_path(arg)
73
+ new_snapshot = Snapshot.from_json(File.read(snapshot_path))
74
+ activate(new_snapshot)
75
+ return
76
+ when :checker_alive
77
+ Slang.log_debug("Chillin' like a villian (waiting on PID #{arg}).")
78
+ sleep(CHILLAX_RETRY)
79
+ return
80
+ when :checker_dead
81
+ Slang.log_debug("Checking worker is dead. RIP. My turn.")
82
+ else # :check
83
+ Slang.log_debug("Looking for a new snapshot.")
84
+ end
85
+
86
+ begin
87
+ modified_at, check_in = fetch_meta(arg)
88
+ shared_state.exclusive_lock do |state|
89
+ state.delete(:checking_pid)
90
+ state[:check_at] = Time.now.to_f + check_in
91
+ state[:modified_at] = modified_at
92
+ end
93
+ sleep(check_in)
94
+ rescue NetworkError => e
95
+ shared_state.exclusive_lock do |state|
96
+ state.delete(:checking_pid)
97
+ state[:check_at] = Time.now.to_f + e.retry_in
98
+ end
99
+ sleep(e.retry_in)
100
+ end
101
+ end
102
+
103
+ def fetch_meta(modified_at)
104
+ Slang.log_debug("Fetching meta.")
105
+ response = get(uri, "If-Modified-Since" => Time.at(modified_at).httpdate)
106
+ case response
107
+ when Net::HTTPNotModified # 304
108
+ Slang.log_info("Snapshot up-to-date.")
109
+ when Net::HTTPOK # 200
110
+ Slang.log_debug("New snapshot? YES!")
111
+ parse_meta(response.body.force_encoding(Encoding::UTF_8))
112
+ when Net::HTTPNotFound # 404
113
+ raise NetworkError.new(UNKNOWN_RETRY), "Unknown project (404)."
114
+ when Net::HTTPGone # 410
115
+ raise NetworkError.new(GONE_RETRY), "Unknown project (410)."
116
+ when Net::HTTPServerError # 5xx
117
+ raise NetworkError.new(TEMPORARY_RETRY), "HTTP #{response.code}"
118
+ else
119
+ raise NetworkError.new(UNEXPECTED_RETRY), "Unexpected response (HTTP #{response.code})"
120
+ end
121
+ last_modified = Time.httpdate(response["Last-Modified"]) rescue Time.now.utc
122
+ max_age = response["Cache-Control"] =~ /max-age=(\d+)/ ? [$1.to_i, 10].max : UNEXPECTED_RETRY
123
+ [ last_modified.to_i, max_age ]
124
+ end
125
+
126
+ def parse_meta(json)
127
+ meta = Oj.strict_load(json, symbol_keys: true)
128
+
129
+ if meta[:id] == @snapshot.id
130
+ Slang.log_debug("Err.. Nope. Already at this snapshot - #{@snapshot.id}")
131
+ return
132
+ end
133
+
134
+ delta_url = meta[:deltas].each_slice(2) do |snapshot_id, url|
135
+ break(url) if snapshot_id == @snapshot.id
136
+ end
137
+ if delta_url
138
+ Slang.log_debug("Delta-delta-delta. How can I help-ya? #{delta_url}")
139
+ begin
140
+ snapshot_json = patch_snapshot(get_asset(delta_url))
141
+ rescue => e
142
+ Slang.log_warn("Unexpected error fetching/applying delta, trying snapshot - #{e.message}")
143
+ end
144
+ end
145
+ snapshot_json ||= get_asset(meta[:url])
146
+ persist_new_snapshot(snapshot_json)
147
+ end
148
+
149
+ def get_asset(url)
150
+ response = get(URI(url))
151
+ case response
152
+ when Net::HTTPOK # 200
153
+ Slang.log_debug("Asset retrieved.")
154
+ response.body.force_encoding(Encoding::UTF_8)
155
+ when Net::HTTPNotFound # 404
156
+ raise NetworkError.new(UNKNOWN_RETRY), "Unknown asset (404)."
157
+ when Net::HTTPGone # 410
158
+ raise NetworkError.new(GONE_RETRY), "Unknown asset (410)."
159
+ when Net::HTTPServerError # 5xx
160
+ raise NetworkError.new(TEMPORARY_RETRY), "HTTP #{response.code}"
161
+ else
162
+ raise NetworkError.new(UNEXPECTED_RETRY), "Unexpected response (HTTP #{response.code})"
163
+ end
164
+ end
165
+
166
+ def persist_new_snapshot(json)
167
+ new_snapshot = Snapshot.from_json(json)
168
+
169
+ File.write(data_file_path(new_snapshot.id), json)
170
+ shared_state.exclusive_lock { |state| state[:snapshot_id] = new_snapshot.id }
171
+ FileUtils.rm(data_file_path(@snapshot.id), force: true)
172
+
173
+ activate(new_snapshot) # last
174
+ end
175
+
176
+ def patch_snapshot(delta_json)
177
+ snapshot_json = File.read(data_file_path(@snapshot.id))
178
+ snapshot_array = Snapshot.array_from_json(snapshot_json)
179
+ delta_array = Oj.strict_load(delta_json)
180
+
181
+ delta_format, snapshot_id, timestamp, default_locale_code, locales_diff, drop, add, change = *delta_array
182
+ raise UpdaterError, "unknown snapshot delta format - #{delta_format}" unless delta_format == 6
183
+ locales = patch(snapshot_array[4], locales_diff)
184
+ translations = snapshot_array[5]
185
+ drop.each { |i| translations.delete_at(i) }
186
+ add.each do |array|
187
+ i = array.shift
188
+ translations.insert(i, array)
189
+ end
190
+ change.each do |array|
191
+ i = array.shift
192
+ translations[i] = patch(translations[i], array)
193
+ end
194
+ new_snapshot_array = [ delta_format, snapshot_id, timestamp, default_locale_code, locales, translations ]
195
+ Oj.dump(new_snapshot_array, mode: :strict)
196
+ end
197
+
198
+ def patch(object, transforms)
199
+ return object if transforms.empty?
200
+ data = Oj.dump(object, mode: :strict).force_encoding(Encoding::BINARY)
201
+ i = 0
202
+ transforms.each do |x|
203
+ if String === x
204
+ x.force_encoding(Encoding::BINARY)
205
+ data.insert(i, x)
206
+ i += x.length
207
+ elsif x < 0
208
+ data.slice!(i, -x)
209
+ else
210
+ i += x
211
+ end
212
+ end
213
+ Oj.strict_load(data.force_encoding(Encoding::UTF_8))
214
+ end
215
+
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,59 @@
1
+ module Slang
2
+ module Updater
3
+
4
+ # A multi-process-safe shared state file. Uses JSON for serialization, and exclusive file locking.
5
+ #
6
+ class SharedState
7
+
8
+ attr_reader :path
9
+ attr_reader :permissions
10
+
11
+ # Create a SharedState instance. State file will be created as necessary.
12
+ #
13
+ # @param [String] pathname of stale file.
14
+ # @param [Fixnum] permissions, defaults to 0644.
15
+ #
16
+ def initialize(path, permissions = 0644)
17
+ @path = File.expand_path(path).freeze
18
+ @permissions = permissions
19
+ end
20
+
21
+ # This method opens the shared state file, obtains an exclusive lock, and reads/deserializes its content. A
22
+ # state hash (possibly empty) is yielded to the block, *while* the exclusive lock is held. The block may mutate
23
+ # the state hash as necessary. When the yield returns, any changes made to the state are serialized and written
24
+ # out the the file. Finally, the file is closed and lock released.
25
+ #
26
+ # @yield [Hash] the current state (keys are symbols).
27
+ # @return result of yielded block
28
+ #
29
+ def exclusive_lock
30
+ File.open(path, File::RDWR | File::CREAT, permissions) do |f|
31
+ f.flock(File::LOCK_EX)
32
+ state = deserialize(f.read)
33
+ state.each_value { |v| v.freeze }
34
+ original_state = state.dup
35
+ ret = yield(state)
36
+ unless original_state == state
37
+ f.rewind
38
+ f.write(serialize(state))
39
+ f.flush
40
+ f.truncate(f.pos)
41
+ end
42
+ ret
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def deserialize(json)
49
+ return {} if json.empty?
50
+ Oj.strict_load(json.force_encoding(Encoding::UTF_8), symbol_keys: true)
51
+ end
52
+
53
+ def serialize(obj)
54
+ Oj.dump(obj, mode: :compat)
55
+ end
56
+
57
+ end # class
58
+ end
59
+ end
@@ -0,0 +1,45 @@
1
+ module Slang
2
+ module Updater
3
+
4
+ # This module adds the ability to squelch activity for a temporary amount of time (or permanently).
5
+ #
6
+ module Squelchable
7
+
8
+ def squelch!(interval, reason=nil)
9
+ if interval
10
+ @squelch_until = Time.now + interval
11
+ Slang.log_warn("Slang activity temporarily disabled for ~#{interval}s. #{reason}") if reason
12
+ else
13
+ @squelch_until = :forever
14
+ Slang.log_error("Slang activity permanently disabled - #{reason}")
15
+ end
16
+ end
17
+
18
+ def squelched?
19
+ if @squelch_until
20
+ if @squelch_until == :forever
21
+ :forever
22
+ else
23
+ wait = @squelch_until - Time.now
24
+ if wait > 0
25
+ wait
26
+ else
27
+ @squelch_until = nil
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ def squelch_thread_activity
34
+ wait = squelched?
35
+ case wait
36
+ when nil; # continue
37
+ when :forever; loop { Thread.stop }
38
+ else sleep(wait)
39
+ end
40
+ end
41
+
42
+ end # module
43
+
44
+ end
45
+ end