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.
- checksums.yaml +7 -0
- data/.gitignore +6 -0
- data/CHANGELOG.md +9 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +28 -0
- data/LICENSE.md +20 -0
- data/README.md +242 -0
- data/Rakefile +12 -0
- data/lib/slang.rb +144 -0
- data/lib/slang/internal.rb +139 -0
- data/lib/slang/railtie.rb +16 -0
- data/lib/slang/snapshot.rb +131 -0
- data/lib/slang/snapshot/locale.rb +35 -0
- data/lib/slang/snapshot/rules.rb +239 -0
- data/lib/slang/snapshot/template.rb +58 -0
- data/lib/slang/snapshot/translation.rb +135 -0
- data/lib/slang/snapshot/warnings.rb +102 -0
- data/lib/slang/updater/abstract.rb +74 -0
- data/lib/slang/updater/development.rb +88 -0
- data/lib/slang/updater/http_helpers.rb +49 -0
- data/lib/slang/updater/key_reporter.rb +92 -0
- data/lib/slang/updater/production.rb +218 -0
- data/lib/slang/updater/shared_state.rb +59 -0
- data/lib/slang/updater/squelchable.rb +45 -0
- data/lib/slang/version.rb +3 -0
- data/slang.gemspec +20 -0
- data/test/data/snapshot.json +64 -0
- data/test/helper.rb +4 -0
- data/test/test_locale.rb +47 -0
- data/test/test_rules.rb +133 -0
- data/test/test_snapshot.rb +132 -0
- data/test/test_template.rb +49 -0
- data/test/test_translation.rb +94 -0
- data/test/test_warnings.rb +123 -0
- metadata +99 -0
@@ -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
|