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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +84 -0
- data/.github/workflows/release.yml +28 -0
- data/.gitignore +11 -0
- data/.rubocop.yml +39 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +723 -0
- data/Gemfile +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +158 -0
- data/Rakefile +41 -0
- data/data/languages/azure.json +285 -0
- data/data/languages/deepl.json +220 -0
- data/data/languages/google.json +399 -0
- data/data/languages/modernmt.json +413 -0
- data/docs/caching.md +185 -0
- data/docs/configuration.md +205 -0
- data/docs/contracts.md +187 -0
- data/docs/development.md +34 -0
- data/docs/errors.md +92 -0
- data/docs/how-it-works.md +145 -0
- data/docs/instrumentation.md +96 -0
- data/docs/languages.md +94 -0
- data/docs/providers.md +379 -0
- data/docs/sql-cache.md +366 -0
- data/lib/generators/translation_diff/install_generator.rb +15 -0
- data/lib/generators/translation_diff/templates/create_translation_diff_tables.rb.erb +24 -0
- data/lib/translation_diff/active_record/support.rb +34 -0
- data/lib/translation_diff/active_record.rb +3 -0
- data/lib/translation_diff/batch.rb +98 -0
- data/lib/translation_diff/call_preparation.rb +47 -0
- data/lib/translation_diff/capabilities.rb +9 -0
- data/lib/translation_diff/configuration/cache_guard_options.rb +39 -0
- data/lib/translation_diff/configuration/cache_ttl_option.rb +30 -0
- data/lib/translation_diff/configuration/option_table.rb +38 -0
- data/lib/translation_diff/configuration/provider_option_owners.rb +40 -0
- data/lib/translation_diff/configuration.rb +135 -0
- data/lib/translation_diff/context.rb +23 -0
- data/lib/translation_diff/dispatcher.rb +57 -0
- data/lib/translation_diff/document.rb +23 -0
- data/lib/translation_diff/errors.rb +44 -0
- data/lib/translation_diff/fragment.rb +33 -0
- data/lib/translation_diff/http_provider.rb +128 -0
- data/lib/translation_diff/instrumentation.rb +25 -0
- data/lib/translation_diff/languages/refresh.rb +70 -0
- data/lib/translation_diff/languages/set.rb +53 -0
- data/lib/translation_diff/languages.rb +30 -0
- data/lib/translation_diff/leaves.rb +22 -0
- data/lib/translation_diff/markup.rb +85 -0
- data/lib/translation_diff/passage.rb +149 -0
- data/lib/translation_diff/preview.rb +3 -0
- data/lib/translation_diff/previewer.rb +78 -0
- data/lib/translation_diff/provider.rb +91 -0
- data/lib/translation_diff/providers/amazon.rb +126 -0
- data/lib/translation_diff/providers/azure.rb +78 -0
- data/lib/translation_diff/providers/deepl.rb +88 -0
- data/lib/translation_diff/providers/google.rb +64 -0
- data/lib/translation_diff/providers/libretranslate.rb +65 -0
- data/lib/translation_diff/providers/modernmt.rb +74 -0
- data/lib/translation_diff/providers/null.rb +20 -0
- data/lib/translation_diff/providers.rb +69 -0
- data/lib/translation_diff/railtie.rb +12 -0
- data/lib/translation_diff/rate_limiters/active_record.rb +92 -0
- data/lib/translation_diff/rate_limiters/redis.rb +59 -0
- data/lib/translation_diff/rate_limiters.rb +9 -0
- data/lib/translation_diff/redaction.rb +45 -0
- data/lib/translation_diff/registry.rb +31 -0
- data/lib/translation_diff/segment.rb +32 -0
- data/lib/translation_diff/segmenters/pragmatic.rb +102 -0
- data/lib/translation_diff/segmenters/simple.rb +122 -0
- data/lib/translation_diff/segmenters.rb +4 -0
- data/lib/translation_diff/sentence_cache.rb +76 -0
- data/lib/translation_diff/stores/active_record.rb +106 -0
- data/lib/translation_diff/stores/memory.rb +34 -0
- data/lib/translation_diff/stores/redis.rb +49 -0
- data/lib/translation_diff/stores.rb +9 -0
- data/lib/translation_diff/tasks/translation_diff.rake +21 -0
- data/lib/translation_diff/translation/request.rb +8 -0
- data/lib/translation_diff/translation/response.rb +35 -0
- data/lib/translation_diff/translation/usage.rb +8 -0
- data/lib/translation_diff/translator.rb +103 -0
- data/lib/translation_diff/version.rb +3 -0
- data/lib/translation_diff.rb +93 -0
- data/translation_diff.gemspec +56 -0
- 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,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
|