translation_diff 1.0.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.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +84 -0
  3. data/.github/workflows/release.yml +28 -0
  4. data/.gitignore +11 -0
  5. data/.rubocop.yml +39 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +723 -0
  8. data/Gemfile +61 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +158 -0
  11. data/Rakefile +41 -0
  12. data/data/languages/azure.json +285 -0
  13. data/data/languages/deepl.json +220 -0
  14. data/data/languages/google.json +399 -0
  15. data/data/languages/modernmt.json +413 -0
  16. data/docs/caching.md +185 -0
  17. data/docs/configuration.md +205 -0
  18. data/docs/contracts.md +187 -0
  19. data/docs/development.md +34 -0
  20. data/docs/errors.md +92 -0
  21. data/docs/how-it-works.md +145 -0
  22. data/docs/instrumentation.md +96 -0
  23. data/docs/languages.md +94 -0
  24. data/docs/providers.md +379 -0
  25. data/docs/sql-cache.md +366 -0
  26. data/lib/generators/translation_diff/install_generator.rb +15 -0
  27. data/lib/generators/translation_diff/templates/create_translation_diff_tables.rb.erb +24 -0
  28. data/lib/translation_diff/active_record/support.rb +34 -0
  29. data/lib/translation_diff/active_record.rb +3 -0
  30. data/lib/translation_diff/batch.rb +98 -0
  31. data/lib/translation_diff/call_preparation.rb +47 -0
  32. data/lib/translation_diff/capabilities.rb +9 -0
  33. data/lib/translation_diff/configuration/cache_guard_options.rb +39 -0
  34. data/lib/translation_diff/configuration/cache_ttl_option.rb +30 -0
  35. data/lib/translation_diff/configuration/option_table.rb +38 -0
  36. data/lib/translation_diff/configuration/provider_option_owners.rb +40 -0
  37. data/lib/translation_diff/configuration.rb +135 -0
  38. data/lib/translation_diff/context.rb +23 -0
  39. data/lib/translation_diff/dispatcher.rb +57 -0
  40. data/lib/translation_diff/document.rb +23 -0
  41. data/lib/translation_diff/errors.rb +44 -0
  42. data/lib/translation_diff/fragment.rb +33 -0
  43. data/lib/translation_diff/http_provider.rb +128 -0
  44. data/lib/translation_diff/instrumentation.rb +25 -0
  45. data/lib/translation_diff/languages/refresh.rb +70 -0
  46. data/lib/translation_diff/languages/set.rb +53 -0
  47. data/lib/translation_diff/languages.rb +30 -0
  48. data/lib/translation_diff/leaves.rb +22 -0
  49. data/lib/translation_diff/markup.rb +85 -0
  50. data/lib/translation_diff/passage.rb +149 -0
  51. data/lib/translation_diff/preview.rb +3 -0
  52. data/lib/translation_diff/previewer.rb +78 -0
  53. data/lib/translation_diff/provider.rb +91 -0
  54. data/lib/translation_diff/providers/amazon.rb +126 -0
  55. data/lib/translation_diff/providers/azure.rb +78 -0
  56. data/lib/translation_diff/providers/deepl.rb +88 -0
  57. data/lib/translation_diff/providers/google.rb +64 -0
  58. data/lib/translation_diff/providers/libretranslate.rb +65 -0
  59. data/lib/translation_diff/providers/modernmt.rb +74 -0
  60. data/lib/translation_diff/providers/null.rb +20 -0
  61. data/lib/translation_diff/providers.rb +69 -0
  62. data/lib/translation_diff/railtie.rb +12 -0
  63. data/lib/translation_diff/rate_limiters/active_record.rb +92 -0
  64. data/lib/translation_diff/rate_limiters/redis.rb +59 -0
  65. data/lib/translation_diff/rate_limiters.rb +9 -0
  66. data/lib/translation_diff/redaction.rb +45 -0
  67. data/lib/translation_diff/registry.rb +31 -0
  68. data/lib/translation_diff/segment.rb +32 -0
  69. data/lib/translation_diff/segmenters/pragmatic.rb +102 -0
  70. data/lib/translation_diff/segmenters/simple.rb +122 -0
  71. data/lib/translation_diff/segmenters.rb +4 -0
  72. data/lib/translation_diff/sentence_cache.rb +76 -0
  73. data/lib/translation_diff/stores/active_record.rb +106 -0
  74. data/lib/translation_diff/stores/memory.rb +34 -0
  75. data/lib/translation_diff/stores/redis.rb +49 -0
  76. data/lib/translation_diff/stores.rb +9 -0
  77. data/lib/translation_diff/tasks/translation_diff.rake +21 -0
  78. data/lib/translation_diff/translation/request.rb +8 -0
  79. data/lib/translation_diff/translation/response.rb +35 -0
  80. data/lib/translation_diff/translation/usage.rb +8 -0
  81. data/lib/translation_diff/translator.rb +103 -0
  82. data/lib/translation_diff/version.rb +3 -0
  83. data/lib/translation_diff.rb +93 -0
  84. data/translation_diff.gemspec +56 -0
  85. metadata +243 -0
data/docs/sql-cache.md ADDED
@@ -0,0 +1,366 @@
1
+ # SQL cache
2
+
3
+ ## What it's for
4
+
5
+ If you already run Postgres or MySQL and do not want to stand up Redis for
6
+ one cache, `TranslationDiff::Stores::ActiveRecord` caches translations in
7
+ the application's own database instead, and
8
+ `TranslationDiff::RateLimiters::ActiveRecord` throttles requests there too.
9
+ Supported means exercised in CI: the suite runs against Postgres, MySQL
10
+ and SQLite on every push.
11
+
12
+ Both are opt-in. Setting `redis_url` still means Redis, exactly as before --
13
+ nothing about an existing application's cache changes until you configure
14
+ one of these:
15
+
16
+ ```ruby
17
+ TranslationDiff.configure do |config|
18
+ config.cache = :active_record
19
+ config.rate_limiter = :active_record
20
+ config.cache_ttl = 30 * 24 * 60 * 60
21
+ end
22
+ ```
23
+
24
+ ## What this store is worse at than Redis
25
+
26
+ Two things, plainly. A read is a query against a table rather than an
27
+ `MGET` against an in-memory store. And the table grows until something
28
+ prunes it -- Redis expires a key for you; this store only stops serving an
29
+ expired row, it does not remove it by itself. See
30
+ [Pruning](#pruning-three-answers-none-imposed) below.
31
+
32
+ ## Transactions
33
+
34
+ A write joins the caller's transaction. A failed write no longer poisons
35
+ it -- the write runs in its own savepoint, so an `ActiveRecord::ActiveRecordError`
36
+ there does not abort a transaction it does not own -- but the rollback
37
+ semantics otherwise stay ordinary: if the caller's transaction rolls back, a
38
+ translation this store just wrote rolls back with it, and the next request
39
+ pays for it again. This is the largest difference between this store and
40
+ the Redis one it substitutes for -- a Redis write is never inside anyone's
41
+ transaction, so it never rolls back with one.
42
+
43
+ That savepoint failure never reaches a caller of `TranslationDiff.translate`
44
+ either, whatever raised it -- see
45
+ [The three write paths fail differently](caching.md#the-three-write-paths-fail-differently).
46
+
47
+ ## What ends up in your log
48
+
49
+ `upsert_all` inlines values into the SQL it sends rather than binding them,
50
+ so a written translation appears verbatim in the host application's own
51
+ ActiveRecord log at `debug` level. That is a separate claim from the one in
52
+ [Configuration](configuration.md): this gem's own `logger` option never
53
+ prints content, but that says nothing about the application's own SQL log,
54
+ which sees the statement ActiveRecord actually sent. A failed write is
55
+ scrubbed -- the `TranslationDiff::Error` it raises carries the adapter's
56
+ error class, never the row, and its cause chain is severed so the original
57
+ exception cannot carry the row into an error tracker either, see
58
+ [`write_multi`](#write_multi) -- but a successful one is not; nothing here redacts your debug-level query log. If
59
+ your application logs SQL at `debug` and what it translates is
60
+ confidential, keep that log above `debug` around this store, or use
61
+ `Stores::Redis` instead.
62
+
63
+ That scrubbing covers every `ActiveRecord::ActiveRecordError` the write path
64
+ can raise, not just a syntax or constraint failure -- see
65
+ [Rails replica routing](#rails-replica-routing) below for the error this
66
+ widened scope was written for.
67
+
68
+ ## The tables
69
+
70
+ Two tables, created by a migration you run once -- see
71
+ [The migration](#the-migration) below. This gem never creates or alters
72
+ either of them itself.
73
+
74
+ ### `translation_diff_translations`
75
+
76
+ | Column | Meaning |
77
+ | --- | --- |
78
+ | `namespace` | `cache_namespace`. Two tenants share this table the way they share a Redis database; `#prune` only prunes its own configured namespace. Limited to 64 characters, the column's own limit; `config.cache_namespace =` refuses a longer value at `configure` time rather than at the first write. |
79
+ | `key_digest` | `Digest::SHA256.hexdigest(key)` -- 64 characters, always. The cache key itself is not stored, only its digest: a variable-length unique index is the one thing guaranteed to bite somebody on MySQL. This also means an entry is not human-readable by its key -- to find one, compute the digest the same way and look that up. |
80
+ | `translation` | The cached value. |
81
+ | `expires_at` | What `cache_ttl` means in SQL. `nil` when `cache_ttl` is unset, or set to `nil` or anything non-positive, which all mean the row never expires on its own. |
82
+ | `created_at`, `updated_at` | Standard ActiveRecord timestamps, set by `upsert_all`. |
83
+
84
+ Unique index on `[namespace, key_digest]` -- the second write of a key
85
+ replaces the first, which is the cache store contract. A separate index on
86
+ `expires_at` backs both the read (which filters on it) and `#prune` (which
87
+ deletes by it).
88
+
89
+ ### `translation_diff_rate_limits`
90
+
91
+ | Column | Meaning |
92
+ | --- | --- |
93
+ | `namespace` | `cache_namespace`, same column, same meaning, same table-sharing as above. |
94
+ | `bucket` | A slice of time narrower than `rate_interval` -- see [The rate limiter](#the-rate-limiter) below for why. |
95
+ | `characters` | Characters counted into that bucket so far. |
96
+
97
+ Unique index on `[namespace, bucket]`, incremented by one guarded upsert
98
+ per check, so two processes hitting the same bucket cannot lose an
99
+ increment between them.
100
+
101
+ ## The migration
102
+
103
+ `rails generate translation_diff:install` writes a timestamped migration
104
+ creating both tables, and lives under `lib/generators/`, loaded only when
105
+ Rails loads generators -- a non-Rails application never sees it and never
106
+ pays for it. **This gem never runs DDL itself:** the migration is the only
107
+ way either table comes into existence, and it is entirely yours to review,
108
+ edit, and run through your own deploy process.
109
+
110
+ For anyone not on Rails -- Sequel, plain ActiveRecord, a DBA who would
111
+ rather write the DDL directly -- here is that migration's body, verbatim
112
+ (the generator fills in the class's version bracket with your own
113
+ `ActiveRecord::Migration.current_version`; `7.1` below is this store's
114
+ floor, not a requirement to target that version specifically):
115
+
116
+ ```ruby
117
+ class CreateTranslationDiffTables < ActiveRecord::Migration[7.1]
118
+ def change
119
+ create_table :translation_diff_translations, if_not_exists: true do |t|
120
+ t.string :namespace, null: false, limit: 64
121
+ t.string :key_digest, null: false, limit: 64
122
+ t.text :translation, null: false, limit: 16_777_215
123
+ t.datetime :expires_at
124
+ t.timestamps
125
+ end
126
+
127
+ add_index :translation_diff_translations, %i[namespace key_digest], unique: true,
128
+ name: "index_translation_diff_translations_on_key", if_not_exists: true
129
+ add_index :translation_diff_translations, :expires_at, if_not_exists: true
130
+
131
+ create_table :translation_diff_rate_limits, if_not_exists: true do |t|
132
+ t.string :namespace, null: false, limit: 64
133
+ t.bigint :bucket, null: false
134
+ t.integer :characters, null: false, default: 0
135
+ end
136
+
137
+ add_index :translation_diff_rate_limits, %i[namespace bucket], unique: true,
138
+ name: "index_translation_diff_rate_limits_on_bucket", if_not_exists: true
139
+ end
140
+ end
141
+ ```
142
+
143
+ Every `create_table` and `add_index` above carries `if_not_exists: true`, so
144
+ a DBA can run this migration twice without the second run failing. `bucket`
145
+ is a `bigint`, not the plain 4-byte integer it looks like it could be: at
146
+ `bucket_width` 1 second -- what `rate_interval` under 24 seconds folds to,
147
+ see [The rate limiter](#the-rate-limiter) below -- `bucket` is the raw Unix
148
+ timestamp, and a 4-byte integer column holding that overflows in January
149
+ 2038 the same way a 32-bit `time_t` does.
150
+
151
+ Both tables are always created together -- there is no generator flag to
152
+ get one without the other, since deciding to use one but not the other
153
+ costs nothing at migration time.
154
+
155
+ `translation` carries `limit: 16_777_215`, which is a no-op on Postgres and
156
+ SQLite -- `text` there has no length ceiling regardless -- and yields
157
+ `MEDIUMTEXT` on MySQL instead of the default `TEXT`, which tops out at
158
+ 65,535 bytes. Without it, one sentence over that size failed the whole
159
+ batch it rode in with on MySQL, and PostgreSQL and SQLite were never
160
+ affected.
161
+
162
+ **An existing MySQL installation** that ran this migration before it
163
+ carried the `limit:` needs one statement, once, through its own deploy
164
+ process -- this gem still never runs DDL for you:
165
+
166
+ ```sql
167
+ ALTER TABLE translation_diff_translations MODIFY translation MEDIUMTEXT NOT NULL;
168
+ ```
169
+
170
+ Postgres and SQLite users have nothing to do here.
171
+
172
+ ## `cache_ttl` becomes `expires_at`
173
+
174
+ `cache_ttl` (in seconds, same option `Stores::Redis` reads) is written
175
+ into each row's `expires_at` at write time. A row past `expires_at` is
176
+ never read, whether or not anything has deleted it yet -- expiry and
177
+ deletion are two different questions here, unlike Redis, where a `SETEX`
178
+ key simply stops existing.
179
+
180
+ A non-positive `cache_ttl` -- `0`, a negative number, or `nil` -- means
181
+ never expires: `expires_at` is written as `nil`, and a `nil` `expires_at`
182
+ is what "never expires on its own" means in the table above. All three
183
+ values fold to that one `nil` in `TranslationDiff.configure` itself, so
184
+ `config.cache_ttl` reads back `nil` for any of them, not just the one you
185
+ set.
186
+
187
+ ## Pruning: three answers, none imposed
188
+
189
+ Deleting an expired row is a separate question from whether it is served,
190
+ and there is no single right answer to "when," so none is forced on you:
191
+
192
+ - **`rake translation_diff:prune`.** Calls `#prune` on the configured cache
193
+ store and, separately, on the configured rate limiter -- deleting rows
194
+ and buckets past their expiry, each in its own configured namespace. Wire
195
+ it into cron, a scheduled job, whatever your host already runs. Either
196
+ side that does not support pruning (`:redis`, or an object of your own)
197
+ is reported and skipped rather than failing the task.
198
+
199
+ The task ships inside this gem, under `lib/translation_diff/tasks/`, not
200
+ in the dev Rakefile -- a dependency's own Rakefile is never loaded by a
201
+ host application's `rake`. On Rails, a `Railtie` wires it into the
202
+ application's own rake tasks automatically, enhanced to depend on the
203
+ `:environment` task so it runs against the application's own
204
+ configuration rather than a default one -- `rake -T` shows it with no
205
+ extra setup. Off Rails, nothing registers it automatically: `load` the
206
+ file yourself (from wherever the gem is installed) to add it to your own
207
+ `Rakefile`, and note that it does **not** get an
208
+ `:environment`-equivalent dependency there -- your own bootstrap needs to
209
+ configure `TranslationDiff` before the task runs, the same way it would
210
+ before any code that calls `translate`. A cron entry that prunes
211
+ silently against the wrong (or unconfigured) configuration is worse than
212
+ no pruning at all.
213
+ - **`config.cache_prune_probability`** (default `0.0`, off). A fraction
214
+ between 0 and 1: on a write, `Stores::ActiveRecord` rolls under it and
215
+ prunes if it wins, in a savepoint of its own so a failed prune cannot
216
+ abort a transaction the caller opened. A value outside `0.0..1.0`, or one
217
+ that is not a number, is refused at `configure` time. Off by default,
218
+ because a translation-serving request
219
+ should not be paying, even occasionally, for someone else's expired rows.
220
+ A value that will not coerce to a number is refused at `configure` time,
221
+ not on the first write that would have consulted it. A prune that fails
222
+ here fails exactly like a failed write, and is rescued the same way -- it
223
+ does not lose the translation it rode in with, see
224
+ [The three write paths fail differently](caching.md#the-three-write-paths-fail-differently).
225
+ - **Doing nothing.** Also a supported answer. An unpruned table is correct
226
+ -- reads still skip every expired row -- just larger than it needs to be.
227
+
228
+ `#prune` only ever deletes rows in its own configured `cache_namespace`; a
229
+ multi-tenant table with several namespaces needs `#prune` called once per
230
+ namespace if every tenant is to be pruned.
231
+
232
+ ## `active_record_base`: a second database
233
+
234
+ `config.active_record_base` (default `::ActiveRecord::Base`) is the class
235
+ `Stores::ActiveRecord` and `RateLimiters::ActiveRecord` build their model
236
+ from. Point it at a class connected to a second database and this store's
237
+ traffic follows that connection instead of your application's primary one:
238
+
239
+ ```ruby
240
+ class TranslationDiffRecord < ActiveRecord::Base
241
+ self.abstract_class = true
242
+ connects_to database: { writing: :translation_diff, reading: :translation_diff }
243
+ end
244
+
245
+ TranslationDiff.configure do |config|
246
+ config.cache = :active_record
247
+ config.active_record_base = TranslationDiffRecord
248
+ end
249
+ ```
250
+
251
+ **This is not a way around a read-replica decision Rails already made for
252
+ the request.** See [Rails replica routing](#rails-replica-routing) below --
253
+ `active_record_base` still matters, but not for that.
254
+
255
+ ## Rails replica routing
256
+
257
+ If your application routes GET requests to a read replica the way the Rails
258
+ guides describe -- `ActiveRecord::Middleware::DatabaseSelector` in the
259
+ middleware stack -- every GET runs with `prevent_writes` on. A page that
260
+ calls `translate` and triggers a cache or rate-limit write during that
261
+ request hits `ActiveRecord::ReadOnlyError`.
262
+
263
+ Pointing `active_record_base` at a class connected to its own writer role,
264
+ or at an entirely separate database, does **not** avoid this. `prevent_writes`
265
+ is enforced by the connection handler for the request as a whole, not per
266
+ model or per connection: verified against a live Rails application,
267
+ pointing `active_record_base` at the application's own writer-role class,
268
+ and separately at a wholly unrelated MySQL database, both still raised
269
+ `ActiveRecord::ReadOnlyError` on the write. `active_record_base` changes
270
+ which database this store's traffic goes to; it does not change whether
271
+ Rails currently permits writes at all.
272
+
273
+ For the cache write specifically, the error is redacted -- see
274
+ [What ends up in your log](#what-ends-up-in-your-log) -- and it does not
275
+ reach your call to `translate` as an exception: the translator rescues it,
276
+ logs it, fires a `cache_error` instrumentation event (provider and error
277
+ class only, never content -- see [Instrumentation](instrumentation.md)),
278
+ and returns the translation anyway. What does not happen is the write: a
279
+ translation served on a GET beneath this middleware is not cached by this
280
+ store, for that request.
281
+
282
+ **The rate limiter fails differently, because it runs earlier.** If
283
+ `config.rate_limiter = :active_record` and the same request hits it,
284
+ `RateLimiters::ActiveRecord#check` cannot record what it is about to allow, so
285
+ it raises `TranslationDiff::Error` naming the adapter's error class -- and
286
+ because the check runs before the provider is ever called, the `translate`
287
+ call fails outright rather than degrading. Nothing has been paid for at
288
+ that point, which is why this one refuses instead of continuing: a limiter
289
+ that cannot count is not a limiter, and quietly translating past it is how
290
+ an application loses its provider account.
291
+
292
+ Two things actually avoid both failures, both checked directly against a
293
+ Rails application with `DatabaseSelector` configured:
294
+
295
+ - **Translate somewhere `DatabaseSelector` is not wrapping.** A background
296
+ job, a POST action, a console session -- anywhere outside a GET this
297
+ middleware routes, there is no `prevent_writes` in effect to begin with.
298
+ - **Wrap the call to permit writes for its duration:**
299
+ ```ruby
300
+ ActiveRecord::Base.connected_to(role: :writing) do
301
+ TranslationDiff.translate(text, from: "en", to: "es")
302
+ end
303
+ ```
304
+
305
+ ## The ActiveRecord version floor
306
+
307
+ **ActiveRecord 7.1 or newer.** `upsert_all` needs `unique_by` on Postgres
308
+ and SQLite, and `record_timestamps:` only landed in 7.1. An older version is
309
+ refused by name, at the point the store is first used, rather than failing
310
+ inside a query with a message that does not say why:
311
+
312
+ ```
313
+ the ActiveRecord cache store needs ActiveRecord 7.1 or newer (found 7.0.0):
314
+ upsert_all takes unique_by and record_timestamps there.
315
+ ```
316
+
317
+ `activerecord` is never a dependency of this gem -- neither in the gemspec
318
+ nor required at load time. `Stores::ActiveRecord#model` and
319
+ `RateLimiters::ActiveRecord#model` `require "active_record"` on first use, so
320
+ an application that never configures `:active_record` never loads it, the
321
+ same way `Stores::Redis` only reaches for `redis` when `redis_url` is
322
+ set. Add `gem "activerecord"` (and a database adapter) to your own Gemfile
323
+ to use either.
324
+
325
+ ## `write_multi`
326
+
327
+ Both `Stores::ActiveRecord` and `Stores::Redis` implement the cache
328
+ store contract's optional `write_multi(pairs)` -- see
329
+ [`write_multi` is optional](caching.md#write_multi-is-optional) for what
330
+ that means, and
331
+ [The three write paths fail differently](caching.md#the-three-write-paths-fail-differently)
332
+ for how a batch write fails differently from a per-key one.
333
+ `Stores::ActiveRecord#write_multi` is a single `upsert_all` for the whole
334
+ batch: a forty-sentence paragraph is one statement, not forty.
335
+
336
+ ## The rate limiter
337
+
338
+ `config.rate_limiter = :active_record` throttles the same way `rate_limit`
339
+ and `rate_interval` already configure the Redis-backed limiter, but counts
340
+ characters into `translation_diff_rate_limits` instead of Redis.
341
+
342
+ The window is sliding, not tumbling. Time is divided into buckets
343
+ `rate_interval / 12` seconds wide (never narrower than 1 second), not one
344
+ bucket per interval, and a check sums every bucket touching the trailing
345
+ `rate_interval` seconds before deciding whether the threshold is exceeded
346
+ -- so the answer does not jump the moment a single wide bucket rolls over,
347
+ the way it would if the whole interval were one bucket. Like the check it
348
+ replaces, it looks at the total *before* adding the new characters, so a
349
+ check that itself pushes the total over the threshold still succeeds; the
350
+ next one raises.
351
+
352
+ The oldest bucket summed is only ever partially inside the window, and it
353
+ is summed in full anyway rather than pro-rated, which errs toward
354
+ stricter. So the window actually enforced is `rate_interval` to
355
+ `rate_interval + rate_interval / 12` seconds (that upper bound is one
356
+ bucket width, floored at 1 second) -- slightly stricter than what was
357
+ configured, never looser. A translation throttle exists to keep a
358
+ provider's quota from being exceeded, not to be a billing meter, so this
359
+ was left as the simpler, safer direction to be wrong in rather than made
360
+ exact.
361
+
362
+ `#prune` here deletes buckets that have fully aged out of the window, in
363
+ the configured namespace. `rake translation_diff:prune` calls it the same
364
+ way it calls the cache store's `#prune`. There is no
365
+ `cache_prune_probability` equivalent for the rate limiter -- the rake task,
366
+ or leaving old buckets in place, are the two supported answers here.
@@ -0,0 +1,15 @@
1
+ # Loaded only when Rails loads generators; nothing in lib/translation_diff.rb requires this file.
2
+ require "translation_diff"
3
+ require "rails/generators"
4
+ require "rails/generators/active_record/migration"
5
+
6
+ # `rails generate translation_diff:install` -- writes the migration for both SQL-backed tables.
7
+ class TranslationDiff::InstallGenerator < Rails::Generators::Base
8
+ include ActiveRecord::Generators::Migration
9
+
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ def create_migration_file
13
+ migration_template "create_translation_diff_tables.rb.erb", "db/migrate/create_translation_diff_tables.rb"
14
+ end
15
+ end
@@ -0,0 +1,24 @@
1
+ class CreateTranslationDiffTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :translation_diff_translations, if_not_exists: true do |t|
4
+ t.string :namespace, null: false, limit: 64
5
+ t.string :key_digest, null: false, limit: 64
6
+ t.text :translation, null: false, limit: 16_777_215
7
+ t.datetime :expires_at
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :translation_diff_translations, %i[namespace key_digest], unique: true,
12
+ name: "index_translation_diff_translations_on_key", if_not_exists: true
13
+ add_index :translation_diff_translations, :expires_at, if_not_exists: true
14
+
15
+ create_table :translation_diff_rate_limits, if_not_exists: true do |t|
16
+ t.string :namespace, null: false, limit: 64
17
+ t.bigint :bucket, null: false
18
+ t.integer :characters, null: false, default: 0
19
+ end
20
+
21
+ add_index :translation_diff_rate_limits, %i[namespace bucket], unique: true,
22
+ name: "index_translation_diff_rate_limits_on_bucket", if_not_exists: true
23
+ end
24
+ end
@@ -0,0 +1,34 @@
1
+ # The lazy require, the version floor and the anonymous model class, shared by the cache store and the limiter.
2
+ module TranslationDiff::ActiveRecord::Support
3
+ MINIMUM_ACTIVE_RECORD = "7.1".freeze
4
+
5
+ def model
6
+ @model ||= build_model
7
+ end
8
+
9
+ private
10
+
11
+ # Any ActiveRecordError, not just StatementInvalid -- ReadOnlyError carries a whole write statement too.
12
+ def ar_error?(error)
13
+ defined?(::ActiveRecord::ActiveRecordError) && error.is_a?(::ActiveRecord::ActiveRecordError)
14
+ end
15
+
16
+ def build_model
17
+ require "active_record"
18
+ ensure_supported_version!
19
+ table = @table_name
20
+ Class.new(@base || ::ActiveRecord::Base) { self.table_name = table }
21
+ rescue LoadError
22
+ raise TranslationDiff::Error,
23
+ "#{active_record_feature} is :active_record but the `activerecord` gem is not available. " \
24
+ 'Add `gem "activerecord"` to your Gemfile.'
25
+ end
26
+
27
+ def ensure_supported_version!
28
+ return if Gem::Version.new(::ActiveRecord::VERSION::STRING) >= Gem::Version.new(MINIMUM_ACTIVE_RECORD)
29
+
30
+ raise TranslationDiff::Error,
31
+ "the #{active_record_component} needs ActiveRecord #{MINIMUM_ACTIVE_RECORD} or newer " \
32
+ "(found #{::ActiveRecord::VERSION::STRING}): #{active_record_upsert_detail}"
33
+ end
34
+ end
@@ -0,0 +1,3 @@
1
+ # The namespace the cache store and the rate limiter's ActiveRecord support live under; see active_record/support.
2
+ module TranslationDiff::ActiveRecord
3
+ end
@@ -0,0 +1,98 @@
1
+ # Segments grouped to one provider request; a reply lands back on them through #apply, never by position after the fact.
2
+ class TranslationDiff::Batch
3
+ # Its own class, so rescuing a sentence too long to send cannot also swallow a registry miss.
4
+ class Error < TranslationDiff::Error; end
5
+
6
+ attr_reader :segments
7
+
8
+ def initialize(segments)
9
+ @segments = segments
10
+ end
11
+
12
+ def texts = segments.map(&:core)
13
+
14
+ # Each translation lands on the segment at the same index in this batch, the segment that produced it.
15
+ def apply(translations)
16
+ ensure_reply_size!(translations)
17
+ segments.each_with_index { |segment, index| segment.translation = translations[index] }
18
+ end
19
+
20
+ class << self
21
+ # Skips blank segments -- a provider has no use for whitespace -- then fills batches within both declared limits.
22
+ def pack(segments, capabilities:)
23
+ filler = Filler.new(capabilities)
24
+ segments.reject(&:empty?).each { |segment| filler.add(segment) }
25
+ filler.batches
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def ensure_reply_size!(translations)
32
+ return if translations.size == segments.size
33
+
34
+ raise TranslationDiff::ResponseError,
35
+ "Provider returned #{translations.size} translations for #{segments.size} segments"
36
+ end
37
+
38
+ # Fills one batch until its count or its cumulative escaped size would cross the limit, then starts the next.
39
+ class Filler
40
+ def initialize(capabilities)
41
+ @capabilities = capabilities
42
+ @batches = []
43
+ @current = []
44
+ @current_size = 0
45
+ end
46
+
47
+ def add(segment)
48
+ size = escaped_size(segment)
49
+ flush if full?(size)
50
+ @current << segment
51
+ @current_size += size
52
+ end
53
+
54
+ def batches
55
+ flush
56
+ @batches
57
+ end
58
+
59
+ private
60
+
61
+ # Escaped, because that is the size a provider's own limit is documented against and what goes over the wire.
62
+ def escaped_size(segment)
63
+ size = CGI.escape(segment.core).size
64
+ ensure_sendable!(segment, size)
65
+ size
66
+ end
67
+
68
+ # A text over either declared limit can never be sent, alone or otherwise, so this raises before any batch fills.
69
+ def ensure_sendable!(segment, size)
70
+ limit = [@capabilities.max_request_size, @capabilities.max_text_size].compact.min
71
+ return if size <= limit
72
+
73
+ raise TranslationDiff::Batch::Error,
74
+ "#{preview(segment.core)} is #{size} characters once escaped, over this provider's limit of #{limit}"
75
+ end
76
+
77
+ # A short prefix locates the offending text without reproducing it -- the rest is the customer's content.
78
+ def preview(text)
79
+ return text.dup if text.size <= 20
80
+
81
+ "#{text[0, 20]}..."
82
+ end
83
+
84
+ def full?(size)
85
+ return false if @current.empty?
86
+
87
+ @current.size >= @capabilities.max_batch_size || @current_size + size > @capabilities.max_request_size
88
+ end
89
+
90
+ def flush
91
+ return if @current.empty?
92
+
93
+ @batches << TranslationDiff::Batch.new(@current)
94
+ @current = []
95
+ @current_size = 0
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,47 @@
1
+ # Shared by Translator and Previewer: turning values into a passage, checking a language pair is
2
+ # supported, and opening the cache -- the parts that answer the same question either way a call is made.
3
+ module TranslationDiff::CallPreparation
4
+ # `include` ignores the includer's own `private` keyword, so visibility has to be declared here.
5
+
6
+ private
7
+
8
+ # opaque_elements comes from the configuration this call is actually using -- a context's own setting must
9
+ # never fall back to Passage's global default.
10
+ def passage(string)
11
+ TranslationDiff::Passage.new(string, segmenter: config.segmenter_instance, language: @from,
12
+ opaque_elements: config.opaque_elements)
13
+ end
14
+
15
+ # A detected language arrives as a String while `to:` is usually a Symbol, so neither type nor case can be assumed.
16
+ def same_language?(from) = from.to_s.casecmp?(@to.to_s)
17
+
18
+ # nil means we ship no data for this provider, and silence is not evidence of absence.
19
+ def ensure_supported!(provider, from)
20
+ return if @assume_supported || !config.validate_languages
21
+
22
+ supported = TranslationDiff::Languages.supports?(provider.cache_key, from: from, to: @to)
23
+ return if supported.nil? || supported
24
+
25
+ raise TranslationDiff::UnsupportedLanguageError,
26
+ "Provider #{provider.cache_key} does not translate #{pair_description(from)}. If it does " \
27
+ "now, pass `assume_supported: true` for this call, or set " \
28
+ "`config.validate_languages = false`, and run `rake languages:refresh`."
29
+ end
30
+
31
+ def pair_description(from) = from.nil? ? "to #{@to}" : "#{from} to #{@to}"
32
+
33
+ # The refusal every caller uses when a provider cannot detect at all; error_class and verb are the only
34
+ # per-caller bits -- Previewer raises a second, stricter refusal of its own even when this one passes.
35
+ def ensure_detects_language!(provider, error_class, verb)
36
+ return if provider.class.capabilities.detects_language?
37
+
38
+ raise error_class, "Provider #{provider.cache_key} cannot detect the source language. Pass `from:` " \
39
+ "with the source language code of the values you are #{verb}."
40
+ end
41
+
42
+ # Reads and writes go through the same cache key format either way, so a hit for one is a hit for the other.
43
+ def cache_for(provider, from)
44
+ TranslationDiff::SentenceCache.new(store: config.cache_store, provider: provider.cache_key,
45
+ from: from, to: @to, options: @options)
46
+ end
47
+ end
@@ -0,0 +1,9 @@
1
+ # Declared, not discovered -- duck-typing left notranslate silently broken on two providers.
2
+ TranslationDiff::Capabilities = Data.define(:max_request_size, :max_batch_size,
3
+ :max_text_size, :html, :notranslate,
4
+ :detects_language, :reports_billing) do
5
+ def html? = html != :none
6
+ def notranslate? = notranslate
7
+ def detects_language? = detects_language
8
+ def reports_billing? = reports_billing
9
+ end
@@ -0,0 +1,39 @@
1
+ # Prepended onto Configuration: fails cache_prune_probability and cache_namespace at configure time, not later.
2
+ module TranslationDiff::Configuration::CacheGuardOptions
3
+ CACHE_NAMESPACE_LIMIT = 64
4
+
5
+ # An ENV var arrives as a String; coerced here so a translate call never meets a bare String's missing
6
+ # #positive?. Handed to super so the declared writer's cache_store invalidation still runs.
7
+ def cache_prune_probability=(value)
8
+ value = nil if value.is_a?(String) && value.strip.empty?
9
+ super(value.nil? ? nil : coerce_probability(value))
10
+ end
11
+
12
+ # Refused here, rather than at the first write's ActiveRecord::ValueTooLong.
13
+ def cache_namespace=(value)
14
+ value = nil if value.is_a?(String) && value.strip.empty?
15
+ raise namespace_too_long(value) if value.is_a?(String) && value.length > CACHE_NAMESPACE_LIMIT
16
+
17
+ super
18
+ end
19
+
20
+ private
21
+
22
+ def coerce_probability(value)
23
+ probability = Float(value)
24
+ raise probability_out_of_range(value) unless (0..1).cover?(probability)
25
+
26
+ probability
27
+ rescue ArgumentError, TypeError
28
+ raise probability_out_of_range(value)
29
+ end
30
+
31
+ def probability_out_of_range(value)
32
+ TranslationDiff::Error.new("cache_prune_probability must be a number between 0 and 1 (got #{value.inspect})")
33
+ end
34
+
35
+ def namespace_too_long(value)
36
+ TranslationDiff::Error.new("cache_namespace must be #{CACHE_NAMESPACE_LIMIT} characters or fewer " \
37
+ "(got #{value.length})")
38
+ end
39
+ end
@@ -0,0 +1,30 @@
1
+ # Prepended onto Configuration: nil sticks here as "never expires", unlike the generic option rule.
2
+ module TranslationDiff::Configuration::CacheTtlOption
3
+ NEVER_ASSIGNED = Object.new.freeze
4
+
5
+ def initialize
6
+ @cache_ttl = NEVER_ASSIGNED
7
+ super
8
+ end
9
+
10
+ # A non-positive number folds into nil too -- a TTL of zero or less can never keep a row. Coerced here,
11
+ # then handed to super so the declared writer's cache_store invalidation still runs.
12
+ def cache_ttl=(value)
13
+ value = nil if value.is_a?(String) && value.strip.empty?
14
+ value = coerce_ttl(value) if value.is_a?(String)
15
+ super(value.is_a?(Numeric) && value <= 0 ? nil : value)
16
+ end
17
+
18
+ def cache_ttl
19
+ @cache_ttl.equal?(NEVER_ASSIGNED) ? self.class.defaults[:cache_ttl] : @cache_ttl
20
+ end
21
+
22
+ private
23
+
24
+ # An ENV var arrives as a String; coerced here so Time.now.utc + @ttl never meets a bare String mid-translation.
25
+ def coerce_ttl(value)
26
+ Integer(value)
27
+ rescue ArgumentError, TypeError
28
+ raise TranslationDiff::Error, "cache_ttl must be a number of seconds (got #{value.inspect})"
29
+ end
30
+ end