Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
7024ea2
feat: let a cache store write a batch in one call
Halvanhelv Sep 10, 2026
9322395
fix: never hand a batching cache store an empty batch
Halvanhelv Sep 10, 2026
e5f1ac9
feat: cache translations in the application's own database
Halvanhelv Sep 10, 2026
e6e7c3a
fix: dedupe write_multi by key before the upsert
Halvanhelv Sep 10, 2026
6d34ab4
feat: ship the migration as a generator and a prune task
Halvanhelv Sep 10, 2026
3c56325
feat: rate limit against the database instead of Redis
Halvanhelv Sep 10, 2026
b60473c
fix: require translation_diff from the install generator
Halvanhelv Sep 10, 2026
88e306a
test: add railties to the dev Gemfile so the generator suite runs
Halvanhelv Sep 10, 2026
777de16
test: replace the rate limiter rollover sleep with a moved clock
Halvanhelv Sep 10, 2026
926c656
test: collapse a two-assertion check to fix a pre-existing rubocop of…
Halvanhelv Sep 10, 2026
46d1603
ci: run the SQL suite against PostgreSQL too
Halvanhelv Sep 10, 2026
66d55fd
fix: sum a sliding window of buckets, not one, in the AR rate limiter
Halvanhelv Sep 10, 2026
feb36c6
docs: document the SQL cache store and rate limiter
Halvanhelv Sep 10, 2026
10b39e6
test: keep the Redis window honest and put Rake's application back
Halvanhelv Sep 10, 2026
7787d60
fix: only pass unique_by to upsert_all when the connection supports it
Halvanhelv Sep 10, 2026
8486f43
fix: ship translation_diff:prune inside the gem, not the dev Rakefile
Halvanhelv Sep 10, 2026
d47a897
fix: wrap the cache store's write in its own savepoint
Halvanhelv Sep 10, 2026
472a136
fix: redact the cache store's own SQL errors before they leave the gem
Halvanhelv Sep 10, 2026
95eb18c
fix: stop dropping the partial oldest bucket in the AR rate limiter
Halvanhelv Sep 10, 2026
1757d23
fix: split write_multi out of the required cache store contract
Halvanhelv Sep 10, 2026
302e974
fix: make cache_ttl = nil reachable and cache_ttl = 0 mean never expires
Halvanhelv Sep 10, 2026
706c446
refactor: extract the shared ActiveRecord plumbing out of the store a…
Halvanhelv Sep 10, 2026
3498dd4
fix: quote the table name in the rate limiter's on_duplicate fragment
Halvanhelv Sep 10, 2026
188d192
fix: make the rate limiter's bucket column a bigint
Halvanhelv Sep 10, 2026
63b4bc1
fix: reject a malformed cache_prune_probability or an over-long cache…
Halvanhelv Sep 10, 2026
364f26c
fix: make the generated migration idempotent
Halvanhelv Sep 10, 2026
6476788
docs: catch up the SQL cache store docs with two rounds of fixes
Halvanhelv Sep 10, 2026
760d3c8
fix: make a non-positive or nil cache_ttl a no-op in RedisCacheStore
Halvanhelv Sep 10, 2026
1f09b45
fix: sever the cause chain on the cache store's redacted error
Halvanhelv Sep 10, 2026
8607168
fix: give prune_sometimes its own savepoint and its own error message
Halvanhelv Sep 10, 2026
a5dd52a
fix: keep the friendly missing-gem error when ActiveRecord truly is n…
Halvanhelv Sep 10, 2026
f99edc5
fix: enforce the 0..1 range cache_prune_probability's own message pro…
Halvanhelv Sep 10, 2026
74ba039
fix: coerce a String cache_ttl the way the neighbouring guards alread…
Halvanhelv Sep 10, 2026
c49e3f2
test: cover write_multi's dedupe against PostgreSQL's own conflict rule
Halvanhelv Sep 10, 2026
825b937
test: pin the rate limiter's prune tests to a clock that holds still
Halvanhelv Sep 10, 2026
fb70478
test: pull the frozen clock into a helper to satisfy rubocop 1.91
Halvanhelv Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,53 @@ jobs:

- run: bundle exec rake test

postgres:
runs-on: ubuntu-latest

services:
postgres:
image: postgres:18
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
ports: ["5432:5432"]

steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4"
bundler-cache: true
- run: bundle exec rake test
env:
TRANSLATION_DIFF_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres

mysql:
runs-on: ubuntu-latest

services:
mysql:
image: mysql:8
env:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
MYSQL_DATABASE: translation_diff_test
options: >-
--health-cmd "mysqladmin ping" --health-interval 10s
--health-timeout 5s --health-retries 5
ports: ["3306:3306"]

steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4"
bundler-cache: true
- run: bundle exec rake test
env:
TRANSLATION_DIFF_DATABASE_URL: trilogy://root@127.0.0.1:3306/translation_diff_test

lint:
runs-on: ubuntu-latest

Expand Down
68 changes: 68 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,74 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
quietly too low: only three of the six built-in providers report billing
at all. See [Instrumentation](docs/instrumentation.md).

- **A SQL-backed cache store and rate limiter, for an application that runs
Postgres or MySQL and does not want Redis for this alone.**
`TranslationDiff::ActiveRecordCacheStore` (`config.cache =
:active_record`) and `TranslationDiff::ActiveRecordRateLimiter`
(`config.rate_limiter = :active_record`) cache translations and throttle
requests in the application's own database, exercised in CI against
Postgres, MySQL and SQLite. Nothing on this branch is breaking: both
are opt-in, the default resolution of `cache` and `rate_limiter` is
untouched, and an application with `redis_url` set keeps getting Redis
exactly as before. `rails generate translation_diff:install` writes the
migration for both tables, idempotently -- every `create_table` and
`add_index` in it carries `if_not_exists: true`; for anyone not on Rails,
its body is in [SQL cache](docs/sql-cache.md) verbatim -- **the gem
itself never runs DDL.** `rake translation_diff:prune` ships inside the
gem: a `Railtie` wires it into a Rails application's own rake tasks
automatically, enhanced with `:environment` so it prunes that
application's own configuration; a non-Rails application loads the task
file itself and
must configure `TranslationDiff` before running it, since the task gets
no `:environment`-equivalent there. ActiveRecord 7.1 or newer is
required when either is used, refused by name at build time rather than
failing inside a query, and `activerecord` is never a dependency of this
gem -- it is required lazily on first use, the same way `redis` already
is. Four new configuration options: `cache_table_name`,
`rate_limit_table_name`, `active_record_base` and
`cache_prune_probability`; the last, along with `cache_namespace`, is
now validated at `configure` time -- a `cache_prune_probability` that
will not coerce to a number, or a `cache_namespace` over 64 characters,
is refused before either reaches a query. See
[SQL cache](docs/sql-cache.md).
- **The SQL cache store's write joins the caller's transaction, and its
errors are redacted, not silent.** A rollback in the caller's transaction
discards translations `ActiveRecordCacheStore` already wrote -- the
largest behavioural difference from `RedisCacheStore`, which is never
inside anyone's transaction. A failed write itself runs in its own
savepoint, so it no longer aborts a transaction it does not own, and the
`TranslationDiff::Error` it raises carries the adapter's error class, not
the row. `upsert_all` inlines values rather than binding them, though, so
a written translation still appears verbatim in the host application's
own ActiveRecord log at `debug` -- unrelated to this gem's own `logger`
option, which never prints content. See
[Transactions](docs/sql-cache.md#transactions) and
[What ends up in your log](docs/sql-cache.md#what-ends-up-in-your-log).
- **`cache_ttl` of `0` or less now means never expires, and `nil` is
reachable through `TranslationDiff.configure`.** Previously `nil` was
documented as meaningful but unreachable through the public
configuration path, and `0` wrote a row whose `expires_at` was already in
the past -- a cache entry that could never hit. Both now fold to the same
`nil` `expires_at`. See
[`cache_ttl` becomes `expires_at`](docs/sql-cache.md#cache_ttl-becomes-expires_at).
- **`ActiveRecordRateLimiter`'s sliding window is conservative, not
exact.** It sums the oldest bucket touching the trailing `rate_interval`
seconds in full, even though that bucket is only ever partially inside
the window, so the window actually enforced is `rate_interval` to
`rate_interval + rate_interval / 12` seconds -- slightly stricter than
configured, never looser. See
[The rate limiter](docs/sql-cache.md#the-rate-limiter).
- `write_multi(pairs)` joins the cache store contract, as an optional
method: a store that implements it gets one call carrying a whole batch
of sentences instead of one call per sentence; a store that does not is
still called once per sentence, exactly as before this method existed --
a custom cache store written against the older contract is unaffected.
All three shipped stores implement it now: `MemoryCacheStore` and
`RedisCacheStore` gain it here too, alongside `ActiveRecordCacheStore`.
The two batching paths fail differently from the per-key one and from
each other -- see
[The three write paths fail differently](docs/caching.md#the-three-write-paths-fail-differently).

### Security

- `Configuration#inspect` and `Provider#inspect` print `[FILTERED]` in place
Expand Down
28 changes: 28 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,31 @@ gem "cgi", "~> 0.5", require: false
# the test suite, which signs against the real library rather than a
# stand-in, has it available.
gem "aws-sigv4", "~> 1.12", require: false

# Not runtime dependencies of the gem (see the gemspec) -- ActiveRecordCacheStore
# and ActiveRecordRateLimiter require active_record lazily on first use, so an
# application caching in Redis never needs it installed. They are here so the
# suite can exercise the stores against a real database rather than a stand-in.
gem "activerecord", "~> 8.1", require: false
gem "sqlite3", "~> 2.9", require: false

# Not a runtime dependency of the gem (see the gemspec) -- only the CI job that
# sets TRANSLATION_DIFF_DATABASE_URL ever opens a Postgres connection, where
# the concurrency the SQL store and rate limiter rest on can actually be
# tested. SQLite has one writer, so most of the suite never needs this gem.
gem "pg", "~> 1.5", require: false

# Not a runtime dependency of the gem (see the gemspec) -- only the CI job that
# sets TRANSLATION_DIFF_DATABASE_URL to a MySQL database ever opens one, where
# the missing supports_insert_conflict_target? behaviour actually bites. Trilogy
# over mysql2: it is a pure Ruby/C socket client with no libmysqlclient headers
# to install, so it builds on a bare CI runner and on this machine alike.
gem "trilogy", "~> 2.9", require: false

# Not a runtime dependency of the gem (see the gemspec) -- the generator under
# lib/generators/ is loaded only when Rails loads generators, so a non-Rails
# application never needs it installed. It is here so
# test/translation_diff/install_generator_test.rb can load it and check the
# generated migration against the schema the rest of the suite runs against,
# instead of skipping itself for want of Rails::Generators::Base.
gem "railties", "~> 8.1", require: false
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,12 @@ See [Providers](docs/providers.md) for configuring each one, the full capabiliti
- **Six built-in providers** -- DeepL, Google Cloud Translation, Azure AI Translator, ModernMT, LibreTranslate, Amazon Translate -- or bring your own by subclassing a small base class
- **HTML aware:** markup is preserved, and `class="notranslate"` can protect a span (provider support varies -- see the caveats below)
- **Any shape:** strings, arrays, and deep hashes go in and come back translated in the same shape
- **Two cache stores:** `MemoryCacheStore` out of the box, `RedisCacheStore` once you configure `redis_url`
- **Three cache stores:** `MemoryCacheStore` out of the box, `RedisCacheStore` once you configure `redis_url`, `ActiveRecordCacheStore` to cache in your own database instead -- see [SQL cache](docs/sql-cache.md)
- **Isolated contexts:** `TranslationDiff.context` for multi-tenant apps and per-request provider overrides, without touching the global configuration
- **Pluggable sentence segmenter:** `pragmatic_segmenter` by default, with a zero-dependency `Simple` alternative
- **HTTP retries, timeouts, and backoff** on every REST-backed provider, via `faraday` and `faraday-retry`
- **One error hierarchy** under `TranslationDiff::Error`, carrying the provider name and HTTP status
- **Optional rate limiting and instrumentation** -- credentials and translated content never appear in a log line
- **Optional rate limiting and instrumentation** -- credentials and translated content never appear in a log line this gem writes (the SQL cache store is the one exception worth knowing before you adopt it -- see [SQL cache](docs/sql-cache.md#what-ends-up-in-your-log))

## Installation

Expand All @@ -140,7 +140,7 @@ This gem loads `ox`, `pragmatic_segmenter`, `faraday`, and `faraday-retry` at re

## Documentation

[Configuration](docs/configuration.md) · [Providers](docs/providers.md) · [Languages](docs/languages.md) · [Caching](docs/caching.md) · [Contracts](docs/contracts.md) · [Instrumentation](docs/instrumentation.md) · [Errors](docs/errors.md) · [How it works](docs/how-it-works.md) · [Upgrading & development](docs/development.md)
[Configuration](docs/configuration.md) · [Providers](docs/providers.md) · [Languages](docs/languages.md) · [Caching](docs/caching.md) · [SQL cache](docs/sql-cache.md) · [Contracts](docs/contracts.md) · [Instrumentation](docs/instrumentation.md) · [Errors](docs/errors.md) · [How it works](docs/how-it-works.md) · [Upgrading & development](docs/development.md)

## Contributing

Expand Down
3 changes: 3 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ namespace :languages do
report[:failed].each { |name, message| warn "failed: #{name}: #{message}" }
end
end

# The task itself ships in lib/, so a host application's own `rake` can load it too -- this just reuses it here.
load File.expand_path("lib/translation_diff/tasks/translation_diff.rake", __dir__)
58 changes: 51 additions & 7 deletions docs/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ key every already-warm cache is keyed on.

## The cache store contract

`config.cache` accepts either a registered name (`:redis`, `:memory`) or an
object satisfying this contract directly:
`config.cache` accepts either a registered name (`:redis`, `:memory`,
`:active_record`) or an object satisfying this contract directly:

```ruby
# Reads several keys at once, returning an array the same length as keys,
Expand All @@ -61,17 +61,61 @@ def read_multi(keys); end

# Writes one key. The second write of the same key replaces the first.
def write(key, value); end

# Writes several pairs at once. Optional -- see "write_multi is optional" below.
def write_multi(pairs); end
```

`test/support/cache_store_contract.rb` is the executable form of this
contract: include `CacheStoreContract` in a test class that defines
`#store`.
`#store`. It only exercises `read_multi` and `write` -- the two required
methods -- so a store that implements only those two still passes it.
`test/support/batching_cache_store_contract.rb` holds the optional half:
include `BatchingCacheStoreContract` too, alongside `CacheStoreContract`,
once `#store` also implements `write_multi`.

Two stores ship with this gem: `TranslationDiff::MemoryCacheStore`, the
Three stores ship with this gem: `TranslationDiff::MemoryCacheStore`, the
default -- a bounded, in-process LRU, not thread-safe by design, evicting by
`cache_max_size` rather than by time; and `TranslationDiff::RedisCacheStore`,
`cache_max_size` rather than by time; `TranslationDiff::RedisCacheStore`,
built from `redis_url` when that is set, expiring entries after `cache_ttl`
and namespacing every key under `cache_namespace`. Neither `redis` nor
`connection_pool` nor `redis-namespace` is a dependency of this gem --
and namespacing every key under `cache_namespace`; and
`TranslationDiff::ActiveRecordCacheStore`, opt-in, caching in the
application's own database -- see [SQL cache](sql-cache.md). Neither `redis`
nor `connection_pool` nor `redis-namespace` is a dependency of this gem --
`RedisCacheStore` takes anything answering to `#with` the way
`ConnectionPool` does, and yields anything `Redis::Namespace` accepts.

## `write_multi` is optional

A store need not implement `write_multi`. `SentenceCache#store` checks: a
store that answers to it gets one call carrying every translated sentence
from the batch; a store that does not is called once per sentence through
`write` instead, exactly as it always was. A custom cache store written
against the contract before `write_multi` existed keeps working unchanged
-- that is what "optional" means here.

All three shipped stores implement it: `MemoryCacheStore` loops over the
pairs (there is no round trip to save in-process); `RedisCacheStore`
pipelines the writes; `ActiveRecordCacheStore` upserts the whole batch in
one statement.

### The three write paths fail differently

Nobody had written this down before: what a partial failure leaves cached
depends on which of these shapes wrote it.

- **No `write_multi` (the per-key path), and `MemoryCacheStore`'s loop.**
Sentences are written one at a time, in order. A failure at sentence N
leaves 1..N-1 written, N failed, and N+1.. never attempted.
- **`RedisCacheStore#write_multi`.** A Redis pipeline is not a
transaction: each `SETEX` in it runs independently of the others, so a
failure in one does not stop its siblings from landing. Which of the
batch actually landed does not follow the sentence order the way the
per-key path's does.
- **`ActiveRecordCacheStore#write_multi`.** One `upsert_all` statement for
the whole batch. It either lands as a whole or it does not -- there is no
partial batch to reason about.

A caller that needs to know which sentences got cached after a failure
needs to know which of these three shapes wrote them; the answer is not the
same for all three.
10 changes: 7 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,19 @@ at all, so an unset environment variable never has to be special-cased.
| --- | --- | --- |
| `provider` | `:deepl` | The translation provider: a registered name or a `TranslationDiff::Provider` of your own. See [Providers](providers.md). |
| `cache` | `nil` | The cache store: a registered name or an object satisfying the [cache store contract](caching.md#the-cache-store-contract). `nil` means "choose for me" -- see below. |
| `cache_ttl` | `604_800` (one week) | Seconds a Redis cache entry is kept. Only meaningful for `RedisCacheStore`; `MemoryCacheStore` evicts by size instead. |
| `cache_namespace` | `"translation-diff"` | Prefix applied to every Redis key this gem writes -- both cache entries and the rate limiter's own bookkeeping. |
| `cache_ttl` | `604_800` (one week) | Seconds an entry is kept before it expires. Read by `RedisCacheStore` (a `SETEX`) and by `ActiveRecordCacheStore` (written into each row's `expires_at`); `MemoryCacheStore` evicts by size instead and ignores it. A non-positive value (`0` or less, or `nil`) means never expires. A String is coerced, so an environment variable works; a value that is not a number is refused at `configure` time rather than mid-translation. See [SQL cache](sql-cache.md#cache_ttl-becomes-expires_at). |
| `cache_namespace` | `"translation-diff"` | Prefix applied to every Redis key this gem writes -- both cache entries and the rate limiter's own bookkeeping. Also the `namespace` column both SQL tables share and the unit `ActiveRecordCacheStore#prune` operates on. At most 64 characters -- longer is refused at `configure` time. See [SQL cache](sql-cache.md#the-tables). |
| `cache_max_size` | `1_000` | Maximum number of entries `MemoryCacheStore` keeps before evicting the least recently used one. |
| `cache_table_name` | `"translation_diff_translations"` | Table `ActiveRecordCacheStore` reads and writes. For a host with its own table-naming convention. See [SQL cache](sql-cache.md). |
| `rate_limit_table_name` | `"translation_diff_rate_limits"` | Table `ActiveRecordRateLimiter` reads and writes. As above. |
| `active_record_base` | `nil` (`::ActiveRecord::Base`) | The class `ActiveRecordCacheStore` and `ActiveRecordRateLimiter` build their model from -- point this at a second database, or a reader/writer role. See [SQL cache](sql-cache.md#active_record_base-a-second-database-or-a-readerwriter-role). |
| `cache_prune_probability` | `0.0` | Chance, per write, that `ActiveRecordCacheStore` prunes expired rows before returning. `0.0` is off, and a value outside `0.0..1.0` is refused at `configure` time; `rake translation_diff:prune` is the other way to prune. See [SQL cache](sql-cache.md#pruning-three-answers-none-imposed). |
| `redis_url` | `ENV["REDIS_URL"]` | Where to connect for the Redis-backed cache store and rate limiter. Setting this is what makes `cache` default to `:redis` instead of `:memory`. |
| `redis_pool_size` | `5` | Size of the connection pool built from `redis_url`. |
| `redis_pool_timeout` | `5` | Seconds to wait for a connection from that pool before raising. |
| `rate_limit` | `nil` | Character threshold per `rate_interval`. Unset means no rate limiting at all. |
| `rate_interval` | `60` | Seconds over which `rate_limit` is measured. **Actually enforced over roughly 5-600 seconds** -- see [The rate limiter contract](contracts.md#the-rate-limiter-contract). |
| `rate_limiter` | `nil` | An object satisfying the [rate limiter contract](contracts.md#the-rate-limiter-contract), to use in place of the built-in Redis-backed one. |
| `rate_limiter` | `nil` | A registered name (`:redis`, `:active_record`) or an object satisfying the [rate limiter contract](contracts.md#the-rate-limiter-contract). `nil` with `rate_limit` set resolves to `:redis`. |
| `segmenter` | `:pragmatic` | The sentence segmenter: a registered name or an object satisfying the [segmenter contract](contracts.md#the-segmenter-contract). |
| `instrumenter` | `nil` | Anything satisfying `ActiveSupport::Notifications`' `#instrument(name, payload) { }` interface. See [Instrumentation and logging](instrumentation.md). |
| `logger` | `nil` | A standard `Logger`. Receives one `debug` line per provider resolution, naming the provider class -- never content and never a credential. See [Instrumentation and logging](instrumentation.md). |
Expand Down
Loading
Loading