i18n-keyless-rails 3.5.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,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+
8
+ module I18nKeyless
9
+ # The three routes of the i18n-keyless wire format this gem uses, with the
10
+ # network policy of the SDKs (conformance/vectors/backoff.json and
11
+ # retry-decision.json): a per-attempt timeout, three attempts with fixed
12
+ # backoff delays on a network error, a timeout, a 429, a 5xx or an unparsable
13
+ # 200 body; no retry on any other status; nothing ever raised.
14
+ #
15
+ # Usage analytics (POST /translate/last-used-translations) follow the node
16
+ # SDK: the cumulative map is POSTed, at most once every 10 s.
17
+ class ApiClient
18
+ # Sent as the `Version` header (the wire dialect: v3 language codes).
19
+ VERSION = I18nKeyless::VERSION
20
+
21
+ # Sent as the `sdk` header. `rails` is registered on the API as a server
22
+ # label, counted like `node`: a server sends no `unique_id`, the API counts
23
+ # it by its source connection, which the client cannot shape.
24
+ SDK = "rails"
25
+
26
+ DEFAULT_URL = "https://api.i18n-keyless.com"
27
+
28
+ ACTION_PARSE_BODY = "parse-body"
29
+ ACTION_NOT_MODIFIED = "not-modified"
30
+ ACTION_FAIL = "fail"
31
+ ACTION_RETRY = "retry"
32
+
33
+ # A network error or a timeout is transient and retried; any other
34
+ # exception ends the call now.
35
+ TRANSIENT_ERRORS = [
36
+ Timeout::Error, SocketError, SystemCallError, EOFError, IOError, OpenSSL::SSL::SSLError
37
+ ].freeze
38
+
39
+ Dictionary = Struct.new(:ok, :not_modified, :translations, :etag, :error, keyword_init: true)
40
+ UsageResult = Struct.new(:ok, :sent, :error, keyword_init: true)
41
+ Outcome = Struct.new(:action, :error, :response, :json, keyword_init: true)
42
+
43
+ attr_reader :api_key, :api_url, :timeout, :retry_delays, :concurrency
44
+ # The backoff sleep, `->(ms) { sleep(ms / 1000.0) }`. Tests replace it.
45
+ attr_accessor :sleeper
46
+
47
+ # @param retry_delays [Array<Integer>] milliseconds between attempts (two entries: three attempts)
48
+ # @param sleeper [#call] receives a number of milliseconds; replaced in tests
49
+ def initialize(api_key:, api_url: DEFAULT_URL, timeout: 10, retry_delays: [500, 1500], concurrency: 30,
50
+ logger: nil, sleeper: nil)
51
+ @api_key = api_key.to_s
52
+ @api_url = api_url.to_s.sub(%r{/+\z}, "")
53
+ @api_url = DEFAULT_URL if @api_url.empty?
54
+ @timeout = timeout
55
+ @retry_delays = retry_delays.map(&:to_i)
56
+ @concurrency = [concurrency.to_i, 1].max
57
+ @logger = logger
58
+ @sleeper = sleeper || ->(ms) { sleep(ms / 1000.0) }
59
+ end
60
+
61
+ def max_attempts
62
+ retry_delays.length + 1
63
+ end
64
+
65
+ # The delay after a failed attempt (1-based), or nil when there is no next attempt.
66
+ def delay_after(failed_attempt)
67
+ retry_delays[failed_attempt - 1]
68
+ end
69
+
70
+ # GET /translate/{lang}: the whole dictionary of one language, or a 304
71
+ # when the ETag still matches.
72
+ def fetch_dictionary(lang, namespace, etag, last_refresh = "")
73
+ result = Dictionary.new(ok: false, not_modified: false, translations: {}, etag: nil, error: nil)
74
+ url = dictionary_url(lang, namespace, etag, last_refresh)
75
+ outcome = call { request(:get, url, etag: etag) }
76
+ if outcome.action == ACTION_NOT_MODIFIED
77
+ result.ok = true
78
+ result.not_modified = true
79
+ return result
80
+ end
81
+ if outcome.action != ACTION_PARSE_BODY
82
+ result.error = outcome.error
83
+ warn("fetch all translations error: #{outcome.error}")
84
+ return result
85
+ end
86
+ dictionary_from(outcome.json, outcome.response, result)
87
+ end
88
+
89
+ # POST /translate/last-used-translations: the cumulative usage map. An
90
+ # empty map is never sent. Same network policy as every other call.
91
+ def send_usage(primary, usage_by_namespace)
92
+ return UsageResult.new(ok: false, sent: false, error: nil) if usage_by_namespace.empty? || api_key.empty?
93
+
94
+ body = { "primaryLanguage" => primary, "translationsUsageByNamespace" => usage_by_namespace }
95
+ outcome = call { request(:post, "#{api_url}/translate/last-used-translations", body: body) }
96
+ if outcome.action != ACTION_PARSE_BODY
97
+ warn("send translations usage error: #{outcome.error}")
98
+ return UsageResult.new(ok: false, sent: true, error: outcome.error)
99
+ end
100
+ json = outcome.json
101
+ warn(json["message"].to_s) if json["message"].to_s != ""
102
+ unless json["ok"]
103
+ error = (json["error"] || "not ok").to_s
104
+ warn("send translations usage error: #{error}")
105
+ return UsageResult.new(ok: false, sent: true, error: error)
106
+ end
107
+ UsageResult.new(ok: true, sent: true, error: nil)
108
+ end
109
+
110
+ # POST /translate for every miss, at most `concurrency` at a time. Failed
111
+ # attempts are retried together, one backoff sleep per round.
112
+ #
113
+ # @param misses [Array<Miss>]
114
+ # @param languages [Array<String>] the configured languages (the primary is added)
115
+ # @return [Hash{String => Hash{String => String}, nil}] translations by language, keyed by miss id; nil when the call failed
116
+ def translate(misses, primary, languages)
117
+ results = {}
118
+ pending = {}
119
+ misses.each do |miss|
120
+ pending[miss.id] = miss
121
+ results[miss.id] = nil
122
+ end
123
+ errors = {}
124
+ attempt = 1
125
+ while attempt <= max_attempts && !pending.empty?
126
+ retry_next = {}
127
+ pending.values.each_slice(concurrency) do |chunk|
128
+ responses = post_chunk(chunk, primary, languages)
129
+ chunk.each do |miss|
130
+ outcome = outcome_of(responses.fetch(miss.id) { RuntimeError.new("no response") })
131
+ errors[miss.id] = outcome.error
132
+ if outcome.action == ACTION_PARSE_BODY
133
+ json = self.class.decode_json(outcome.response)
134
+ if json.nil?
135
+ errors[miss.id] = "invalid JSON"
136
+ retry_next[miss.id] = miss
137
+ next
138
+ end
139
+ results[miss.id] = translation_from(json, miss)
140
+ next
141
+ end
142
+ if outcome.action == ACTION_RETRY
143
+ retry_next[miss.id] = miss
144
+ next
145
+ end
146
+ # fail (or a 304 that makes no sense on a POST): give up on this miss now
147
+ warn("translate error for \"#{miss.key}\": #{outcome.error}")
148
+ end
149
+ end
150
+ pending = retry_next
151
+ sleep_after(attempt) unless pending.empty?
152
+ attempt += 1
153
+ end
154
+ pending.each_value do |miss|
155
+ warn("translate error for \"#{miss.key}\": #{errors[miss.id] || 'unknown error'}")
156
+ end
157
+ results
158
+ end
159
+
160
+ # The body of one POST /translate (conformance/vectors/translate-request.json).
161
+ def translate_body(miss, primary, languages)
162
+ body = {
163
+ "key" => miss.key,
164
+ "context" => miss.context,
165
+ # The default namespace is omitted on the wire, like the SDKs do.
166
+ "namespace" => miss.namespace == Translator::DEFAULT_NAMESPACE ? nil : miss.namespace,
167
+ # The configured list plus the primary, never the locale that missed: the
168
+ # API stores this list as the project's languages (the react SDK sends its
169
+ # required `supported` list the same way).
170
+ "languages" => (Array(languages) + [primary]).uniq,
171
+ "primaryLanguage" => primary
172
+ }
173
+ body.reject { |_, value| value.nil? || value == "" }
174
+ end
175
+
176
+ # What one attempt's answer does to the call. Statuses follow
177
+ # conformance/vectors/retry-decision.json; `error` is the reason phrase
178
+ # when non-empty, else `HTTP <code>`.
179
+ def self.decide(status, reason = nil)
180
+ status = status.to_i
181
+ error = reason.to_s.empty? ? "HTTP #{status}" : reason.to_s
182
+ return Outcome.new(action: ACTION_PARSE_BODY, error: "") if status == 200
183
+ return Outcome.new(action: ACTION_NOT_MODIFIED, error: "") if status == 304
184
+ return Outcome.new(action: ACTION_RETRY, error: error) if status == 429 || status >= 500
185
+
186
+ Outcome.new(action: ACTION_FAIL, error: error)
187
+ end
188
+
189
+ # A network error or a timeout is transient; the SDKs spell a timeout `timeout`.
190
+ def self.error_for(exception)
191
+ return "timeout" if exception.is_a?(Timeout::Error) || exception.message =~ /timed out|timeout/i
192
+
193
+ message = exception.message.to_s
194
+ message.empty? ? exception.class.name : message
195
+ end
196
+
197
+ def self.transient?(exception)
198
+ TRANSIENT_ERRORS.any? { |klass| exception.is_a?(klass) }
199
+ end
200
+
201
+ def self.decode_json(response)
202
+ json = JSON.parse(response.body.to_s)
203
+ json.is_a?(Hash) ? json : nil
204
+ rescue JSON::ParserError, TypeError
205
+ nil
206
+ end
207
+
208
+ # The URL of a bulk fetch (conformance/vectors/dictionary-request.json).
209
+ # With an ETag in hand, freshness travels in If-None-Match and the URL
210
+ # stays stable, so shared HTTP caches can hold it. Without one, the delta
211
+ # cursor travels as `last_refresh`: this gem keeps no cursor and sends it
212
+ # empty, which asks for the whole dictionary.
213
+ def dictionary_url(lang, namespace, etag, last_refresh = "")
214
+ # The default namespace is omitted from the query so a plain install
215
+ # hits the exact same URL as the SDKs.
216
+ namespace_query = namespace == Translator::DEFAULT_NAMESPACE ? "" : "&namespace=#{encode(namespace)}"
217
+ query = if etag
218
+ namespace_query.empty? ? "" : "?#{namespace_query[1..]}"
219
+ else
220
+ "?last_refresh=#{last_refresh.nil? ? 'null' : last_refresh}#{namespace_query}"
221
+ end
222
+ "#{api_url}/translate/#{lang}#{query}"
223
+ end
224
+
225
+ # The headers every request carries.
226
+ def headers
227
+ {
228
+ "Content-Type" => "application/json",
229
+ "Accept" => "application/json",
230
+ "Authorization" => "Bearer #{api_key}",
231
+ "Version" => VERSION,
232
+ "sdk" => SDK
233
+ }
234
+ end
235
+
236
+ private
237
+
238
+ # One call with the shared network policy: up to `max_attempts` attempts,
239
+ # a backoff sleep after each failed one. Ends with `parse-body` (and the
240
+ # decoded JSON), `not-modified`, or `fail` with the last error.
241
+ def call
242
+ error = ""
243
+ (1..max_attempts).each do |attempt|
244
+ outcome = begin
245
+ outcome_of(yield)
246
+ rescue Exception => e # rubocop:disable Lint/RescueException -- nothing ever raises out of a translation
247
+ raise if e.is_a?(NoMemoryError) || e.is_a?(SignalException) || e.is_a?(SystemExit)
248
+
249
+ outcome_of(e)
250
+ end
251
+ error = outcome.error
252
+ return outcome if outcome.action == ACTION_NOT_MODIFIED
253
+
254
+ if outcome.action == ACTION_PARSE_BODY
255
+ json = self.class.decode_json(outcome.response)
256
+ if json
257
+ outcome.json = json
258
+ return outcome
259
+ end
260
+ # An unparsable 200 body is a failed attempt, retried like a 5xx.
261
+ outcome.action = ACTION_RETRY
262
+ error = "invalid JSON"
263
+ end
264
+ break if outcome.action == ACTION_FAIL
265
+
266
+ sleep_after(attempt)
267
+ end
268
+ Outcome.new(action: ACTION_FAIL, error: error, response: nil, json: nil)
269
+ end
270
+
271
+ # @return [Hash{String => Net::HTTPResponse, Exception}] keyed by miss id
272
+ def post_chunk(chunk, primary, languages)
273
+ threads = chunk.map do |miss|
274
+ Thread.new do
275
+ Thread.current.report_on_exception = false
276
+ [miss.id, begin
277
+ request(:post, "#{api_url}/translate", body: translate_body(miss, primary, languages))
278
+ rescue Exception => e # rubocop:disable Lint/RescueException
279
+ raise if e.is_a?(NoMemoryError) || e.is_a?(SignalException) || e.is_a?(SystemExit)
280
+
281
+ e
282
+ end]
283
+ end
284
+ end
285
+ threads.to_h(&:value)
286
+ end
287
+
288
+ def outcome_of(answer)
289
+ if answer.is_a?(Exception)
290
+ return Outcome.new(
291
+ action: self.class.transient?(answer) ? ACTION_RETRY : ACTION_FAIL,
292
+ error: self.class.error_for(answer),
293
+ response: nil
294
+ )
295
+ end
296
+ outcome = self.class.decide(answer.code, answer.message)
297
+ outcome.response = answer
298
+ outcome
299
+ end
300
+
301
+ def dictionary_from(json, response, result)
302
+ unless json["ok"]
303
+ result.error = (json["error"] || "not ok").to_s
304
+ warn("fetch all translations error: #{result.error}")
305
+ return result
306
+ end
307
+ warn(json["message"].to_s) if json["message"].to_s != ""
308
+ translations = json.dig("data", "translations")
309
+ result.ok = true
310
+ result.translations = translations.is_a?(Hash) ? translations.select { |_, v| v.is_a?(String) } : {}
311
+ etag = response["ETag"].to_s
312
+ result.etag = etag.empty? ? nil : etag
313
+ result
314
+ end
315
+
316
+ def translation_from(json, miss)
317
+ unless json["ok"]
318
+ warn("translate error for \"#{miss.key}\": #{json['error'] || 'not ok'}")
319
+ return nil
320
+ end
321
+ warn(json["message"].to_s) if json["message"].to_s != ""
322
+ translation = json.dig("data", "translation")
323
+ translation.is_a?(Hash) ? translation.select { |_, v| v.is_a?(String) } : {}
324
+ end
325
+
326
+ def request(method, url, body: nil, etag: nil)
327
+ uri = URI.parse(url)
328
+ req = method == :get ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
329
+ headers.each { |name, value| req[name] = value }
330
+ req["If-None-Match"] = etag if etag
331
+ req.body = JSON.generate(body) if body
332
+ http = Net::HTTP.new(uri.host, uri.port)
333
+ http.use_ssl = uri.scheme == "https"
334
+ http.open_timeout = timeout
335
+ http.read_timeout = timeout
336
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
337
+ http.start { |connection| connection.request(req) }
338
+ end
339
+
340
+ def encode(value)
341
+ URI.encode_www_form_component(value).gsub("+", "%20")
342
+ end
343
+
344
+ def sleep_after(failed_attempt)
345
+ delay = delay_after(failed_attempt)
346
+ @sleeper.call(delay) if delay && delay.positive?
347
+ end
348
+
349
+ def warn(message)
350
+ @logger&.warn("i18n-keyless: #{message}")
351
+ rescue StandardError
352
+ # Logging must never take a translation down.
353
+ end
354
+ end
355
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n"
4
+
5
+ module I18nKeyless
6
+ # The I18n backend. Chained AFTER the application's own backend
7
+ # (`I18n::Backend::Chain.new(I18n.backend, I18nKeyless::Backend.new)`), so a
8
+ # key found in `config/locales/*.yml` wins and only what the YAML files do
9
+ # not have reaches the API.
10
+ #
11
+ # `I18n.t("Welcome to our app")` therefore resolves through i18n-keyless,
12
+ # while `I18n.t("users.index.title")` or `t(:hello)` stay Rails keys (see
13
+ # `I18nKeyless.keyless_key?`) and are never sent.
14
+ #
15
+ # `context:` and `namespace:` travel as I18n options:
16
+ # `t("8 heures", context: "duration")` is stored as `8 heures__duration`.
17
+ class Backend
18
+ include I18n::Backend::Base
19
+
20
+ def available_locales
21
+ []
22
+ end
23
+
24
+ def initialized?
25
+ true
26
+ end
27
+
28
+ def reload!
29
+ I18nKeyless.translator.reset_loaded! if I18nKeyless.enabled?
30
+ self
31
+ end
32
+
33
+ def eager_load!
34
+ self
35
+ end
36
+
37
+ def store_translations(_locale, _data, _options = {}); end
38
+
39
+ def translations(*)
40
+ {}
41
+ end
42
+
43
+ protected
44
+
45
+ def lookup(locale, key, scope = [], options = {})
46
+ return nil unless I18nKeyless.enabled?
47
+ return nil unless key.is_a?(String) && (scope.nil? || Array(scope).empty?) && I18nKeyless.keyless_key?(key)
48
+
49
+ I18nKeyless.translator.lookup(locale.to_s, key, context: options[:context], namespace: options[:namespace])
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nKeyless
4
+ # The configuration, with the same names and defaults as the Laravel port.
5
+ # Every value is read from the environment first (`I18N_KEYLESS_*`), then
6
+ # overridden by `I18nKeyless.configure { |c| ... }`.
7
+ class Config
8
+ # A key matching this is a Rails key (`hello`, `users.index.title`): it is
9
+ # left to the YAML files. Everything else is a keyless source string.
10
+ DEFAULT_RAILS_KEY_PATTERN = /\A[a-z0-9_]+(\.[a-z0-9_]+)*\z/
11
+
12
+ FALSE_VALUES = %w[false 0 off no].freeze
13
+
14
+ # `false` switches the gem off: Rails behaves as without it.
15
+ attr_accessor :enabled
16
+ # Your project's key. Without it the gem stays inactive.
17
+ attr_accessor :api_key
18
+ # The official service, or your own backend / proxy speaking the same wire format.
19
+ attr_accessor :api_url
20
+ # The language the source strings are written in. Default: I18n.default_locale.
21
+ attr_accessor :primary
22
+ # REQUIRED for translation: every language the app serves ("en,fr,es" or an array).
23
+ attr_accessor :languages
24
+ # The i18n-keyless namespace of the `t()` strings. Default "default".
25
+ attr_accessor :namespace
26
+ # An ActiveSupport::Cache::Store. Default: Rails.cache, else a MemoryStore.
27
+ attr_accessor :cache
28
+ # Seconds a dictionary is served without asking the API. Default 3600.
29
+ attr_accessor :cache_ttl
30
+ # Prefix of every cache key the gem writes.
31
+ attr_accessor :cache_prefix
32
+ # HTTP timeout in seconds, per attempt.
33
+ attr_accessor :timeout
34
+ # Backoff in milliseconds between retries: two entries, two retries.
35
+ attr_accessor :retry
36
+ # Maximum POST /translate requests in flight at once.
37
+ attr_accessor :concurrency
38
+ # Usage analytics (the date each string was last served), like the node SDK.
39
+ attr_accessor :usage
40
+ # An ActiveJob queue name: misses are sent from a TranslateMissingKeysJob instead of after the response.
41
+ attr_accessor :queue
42
+ # Where warnings go. Default: Rails.logger, else STDERR.
43
+ attr_accessor :logger
44
+ # The Rails-key rule (see DEFAULT_RAILS_KEY_PATTERN). `nil`: every string is keyless.
45
+ attr_accessor :rails_key_pattern
46
+
47
+ def initialize(env = ENV)
48
+ @enabled = truthy?(env.fetch("I18N_KEYLESS_ENABLED", "true"))
49
+ @api_key = env["I18N_KEYLESS_API_KEY"]
50
+ @api_url = env["I18N_KEYLESS_API_URL"]
51
+ @primary = env["I18N_KEYLESS_PRIMARY_LANG"]
52
+ @languages = env["I18N_KEYLESS_LANGUAGES"]
53
+ @namespace = env["I18N_KEYLESS_NAMESPACE"]
54
+ @cache = nil
55
+ @cache_ttl = Integer(env.fetch("I18N_KEYLESS_CACHE_TTL", 3600), exception: false) || 3600
56
+ @cache_prefix = "i18n-keyless"
57
+ @timeout = 10
58
+ @retry = [500, 1500]
59
+ @concurrency = 30
60
+ @usage = truthy?(env.fetch("I18N_KEYLESS_USAGE", "true"))
61
+ @queue = env["I18N_KEYLESS_QUEUE"]
62
+ @logger = nil
63
+ @rails_key_pattern = DEFAULT_RAILS_KEY_PATTERN
64
+ end
65
+
66
+ def enabled?
67
+ truthy?(enabled) && !api_key.to_s.strip.empty?
68
+ end
69
+
70
+ # The primary language as an i18n-keyless code: the configured one, else
71
+ # I18n.default_locale mapped, else "en".
72
+ def resolved_primary
73
+ Locale.to_lang(primary&.to_s) || Locale.to_lang(I18n.default_locale.to_s) || "en"
74
+ end
75
+
76
+ # The configured languages as i18n-keyless codes, deduplicated. A comma
77
+ # separated string ("en,fr,es") or an array of locales.
78
+ def resolved_languages
79
+ list = languages
80
+ list = list.split(",") if list.is_a?(String)
81
+ Array(list).filter_map { |tag| Locale.to_lang(tag.to_s) }.uniq
82
+ end
83
+
84
+ def resolved_api_url
85
+ url = api_url.to_s.strip.sub(%r{/+\z}, "")
86
+ url.empty? ? ApiClient::DEFAULT_URL : url
87
+ end
88
+
89
+ def resolved_namespace
90
+ value = namespace.to_s
91
+ value.empty? ? Translator::DEFAULT_NAMESPACE : value
92
+ end
93
+
94
+ def resolved_cache
95
+ return cache if cache
96
+ return ::Rails.cache if defined?(::Rails) && ::Rails.respond_to?(:cache) && ::Rails.cache
97
+
98
+ @memory_store ||= ActiveSupport::Cache::MemoryStore.new
99
+ end
100
+
101
+ def resolved_logger
102
+ return logger if logger
103
+ return ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
104
+
105
+ @stderr_logger ||= Logger.new($stderr)
106
+ end
107
+
108
+ def resolved_retry
109
+ Array(self.retry).map(&:to_i)
110
+ end
111
+
112
+ def usage?
113
+ truthy?(usage)
114
+ end
115
+
116
+ private
117
+
118
+ def truthy?(value)
119
+ return value if value == true || value == false
120
+ return false if value.nil?
121
+
122
+ !FALSE_VALUES.include?(value.to_s.strip.downcase) && !value.to_s.strip.empty?
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+
5
+ module I18nKeyless
6
+ # The per-language dictionaries in an ActiveSupport cache store (any: memory,
7
+ # file, Redis, Memcached, the database), plus the cross-request guard that
8
+ # keeps one miss from being POSTed by every request.
9
+ #
10
+ # A dictionary entry is stored forever: `ttl` is not its lifetime but the time
11
+ # it is served without asking the API. A stale entry is still served, and
12
+ # revalidated with its ETag after the response (a 304 keeps it as is).
13
+ #
14
+ # Entry: { translations: Hash, etag: String|nil, fetched_at: Integer, failed: Boolean }
15
+ class DictionaryStore
16
+ # Seconds a failed fetch is remembered before the API is asked again.
17
+ FAILURE_TTL = 60
18
+
19
+ # Minimum seconds between two usage POSTs, across every process (the node SDK's debounce).
20
+ USAGE_FLUSH_SECONDS = 10
21
+
22
+ attr_reader :cache, :prefix, :ttl, :api_key_hash
23
+
24
+ def initialize(cache:, prefix:, ttl:, api_key_hash:)
25
+ @cache = cache
26
+ @prefix = prefix
27
+ @ttl = [ttl.to_i, 0].max
28
+ @api_key_hash = api_key_hash
29
+ end
30
+
31
+ def self.hash_key(api_key)
32
+ Digest::SHA1.hexdigest(api_key.to_s)[0, 8]
33
+ end
34
+
35
+ def get(lang, namespace)
36
+ entry = cache.read(key(lang, namespace))
37
+ return nil unless entry.is_a?(Hash)
38
+
39
+ entry = entry.transform_keys(&:to_sym)
40
+ entry[:translations].is_a?(Hash) ? entry : nil
41
+ end
42
+
43
+ def put(lang, namespace, translations, etag, failed: false)
44
+ entry = { translations: translations, etag: etag, fetched_at: Time.now.to_i, failed: failed }
45
+ cache.write(key(lang, namespace), entry)
46
+ entry
47
+ end
48
+
49
+ # After a 304: same dictionary, same ETag, fresh again.
50
+ def touch(lang, namespace)
51
+ entry = get(lang, namespace)
52
+ return if entry.nil?
53
+
54
+ entry[:fetched_at] = Time.now.to_i
55
+ entry[:failed] = false
56
+ cache.write(key(lang, namespace), entry)
57
+ end
58
+
59
+ # Adds freshly translated lines to a stored dictionary (after POST /translate),
60
+ # and marks it stale so the next request revalidates with the API.
61
+ def merge(lang, namespace, lines)
62
+ entry = get(lang, namespace) || { translations: {}, etag: nil, fetched_at: 0, failed: false }
63
+ entry[:translations] = entry[:translations].merge(lines)
64
+ entry[:fetched_at] = 0
65
+ cache.write(key(lang, namespace), entry)
66
+ end
67
+
68
+ def mark_stale(lang, namespace)
69
+ entry = get(lang, namespace)
70
+ return if entry.nil?
71
+
72
+ entry[:fetched_at] = 0
73
+ cache.write(key(lang, namespace), entry)
74
+ end
75
+
76
+ def stale?(entry)
77
+ max_age = entry[:failed] ? [FAILURE_TTL, ttl].min : ttl
78
+ (Time.now.to_i - entry[:fetched_at].to_i) > max_age
79
+ end
80
+
81
+ # Claims a miss for this process: true when nobody POSTed it during the last
82
+ # `ttl` seconds. Atomic on stores that honour `unless_exist` (Redis,
83
+ # Memcached, the database store, the file store, the memory store).
84
+ def claim_miss(miss)
85
+ return true if ttl.zero?
86
+
87
+ cache.write(miss_key(miss), 1, expires_in: ttl, unless_exist: true) ? true : false
88
+ end
89
+
90
+ # After a failed POST: let a later request try again.
91
+ def release_miss(miss)
92
+ cache.delete(miss_key(miss))
93
+ end
94
+
95
+ # The cumulative usage map, `{ namespace => { "key__context" => "YYYY-MM-DD" } }`,
96
+ # never cleared (the node SDK keeps it for the life of the process; here it
97
+ # lives in the cache for the life of the cache).
98
+ def usage
99
+ usage = cache.read(usage_key)
100
+ usage.is_a?(Hash) ? usage : {}
101
+ end
102
+
103
+ # Merges freshly recorded dates into the stored map. True when a date
104
+ # changed (a new key, or a key seen on a new day).
105
+ def merge_usage(recorded)
106
+ usage = self.usage
107
+ changed = false
108
+ recorded.each do |namespace, keys|
109
+ keys.each do |key, date|
110
+ next if usage.dig(namespace, key) == date
111
+
112
+ (usage[namespace] ||= {})[key] = date
113
+ changed = true
114
+ end
115
+ end
116
+ if changed
117
+ cache.write(usage_key, usage)
118
+ cache.write("#{usage_key}:dirty", true)
119
+ end
120
+ changed
121
+ end
122
+
123
+ # True while the stored map holds changes the API has not received.
124
+ def usage_dirty?
125
+ cache.read("#{usage_key}:dirty") ? true : false
126
+ end
127
+
128
+ def clear_usage_dirty
129
+ cache.delete("#{usage_key}:dirty")
130
+ end
131
+
132
+ # Claims the right to POST usage now: false when a POST left less than 10 s ago.
133
+ def claim_usage_slot
134
+ cache.write("#{usage_key}:lock", 1, expires_in: USAGE_FLUSH_SECONDS, unless_exist: true) ? true : false
135
+ end
136
+
137
+ def usage_key
138
+ "#{prefix}:#{api_key_hash}:usage"
139
+ end
140
+
141
+ def key(lang, namespace)
142
+ "#{prefix}:#{api_key_hash}:dict:#{namespace}:#{lang}"
143
+ end
144
+
145
+ private
146
+
147
+ def miss_key(miss)
148
+ "#{prefix}:#{api_key_hash}:miss:#{Digest::SHA1.hexdigest(miss.id)}"
149
+ end
150
+ end
151
+ end