Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 20 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Breaking

- **One `TranslationDiff::RateLimitExceeded`, whichever limiter noticed it.**
The two limiters used to raise two same-named classes under their own
namespaces, so an application that rescued one and then switched
`rate_limiter` from `:redis` to `:active_record` quietly stopped catching
it. See [Errors](docs/errors.md).

- **Language validation is on by default.** `TranslationDiff.translate` now
refuses, before making a request, any source/target pair the shipped data
doesn't list for that provider -- raising
Expand Down Expand Up @@ -42,6 +48,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Rescue `TranslationDiff::Error` to catch both. See
[Errors](docs/errors.md).

- **The cache stores, rate limiters and ActiveRecord plumbing moved under
their registry's own namespace; nothing was left behind at the old
name.** `TranslationDiff::MemoryCacheStore`, `RedisCacheStore` and
`ActiveRecordCacheStore` are now `TranslationDiff::Stores::Memory`,
`Stores::Redis` and `Stores::ActiveRecord`; `RedisRateLimiter` and
`ActiveRecordRateLimiter` are now `TranslationDiff::RateLimiters::Redis`
and `RateLimiters::ActiveRecord`; `TranslationDiff::ActiveRecordSupport`
is now `TranslationDiff::ActiveRecord::Support`. `config.cache = :redis`
and the rest of the symbol-keyed configuration are unaffected -- only
the constant a name resolves to changed. See
[Caching](docs/caching.md) and [SQL cache](docs/sql-cache.md).

### Added

- **Every event from one `translate` call now shares a `call_id`.** Generated
Expand Down Expand Up @@ -179,8 +197,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
`rate_limit` now falls back to the limiter's own default -- 8,000
characters per `rate_interval`, the same for both shipped limiters. See
[Configuration options](docs/configuration.md#configuration-options).
- **A refused request now says what it hit.** Both `RateLimitExceeded`
classes raise with a message naming the namespace, the threshold and the
- **A refused request now says what it hit.** `RateLimitExceeded`
carries a message naming the namespace, the threshold and the
interval (`"rate limit reached for translation-diff: 8000 characters per
60 seconds"`) -- never the content that tripped it. See
[Errors](docs/errors.md).
Expand Down
8 changes: 4 additions & 4 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ source "https://rubygems.org"
gemspec

# Not runtime dependencies of the gem (see the gemspec) -- Configuration#
# redis_pool requires them lazily, and RedisCacheStore/RedisRateLimiter
# redis_pool requires them lazily, and Stores::Redis/RateLimiters::Redis
# duck-type against whatever a caller's connection pool yields. They are
# only here so the test suite, which builds real pools against these
# classes, has them available.
gem "connection_pool", "~> 2.4", require: false
gem "redis", "~> 5.0", require: false
gem "redis-namespace", "~> 1.11", require: false

# Not a runtime dependency of the gem (see the gemspec) -- RedisRateLimiter
# Not a runtime dependency of the gem (see the gemspec) -- RateLimiters::Redis
# requires it lazily on the first check, so an application that configures no
# rate limit never needs it installed. It is only here so the test suite,
# which exercises the limiter against the real Ratelimit class rather than a
Expand All @@ -32,8 +32,8 @@ gem "cgi", "~> 0.5", require: false
# 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
# Not runtime dependencies of the gem (see the gemspec) -- TranslationDiff::Stores::ActiveRecord
# and TranslationDiff::RateLimiters::ActiveRecord 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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ 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
- **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)
- **Three cache stores:** `Stores::Memory` out of the box, `Stores::Redis` once you configure `redis_url`, `Stores::ActiveRecord` 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`
Expand Down
24 changes: 12 additions & 12 deletions docs/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
One entry per sentence, keyed by the provider's `cache_key`, the lowercased
source and target language codes, a digest of the provider options that call
passed (`formality:`, a glossary id, ...), and a digest of the sentence
itself. `RedisCacheStore` prefixes all of that with `cache_namespace`.
itself. `Stores::Redis` prefixes all of that with `cache_namespace`.

**No provider's `*_api_base` option is part of the key.** Two configurations
pointing `deepl_api_base` (or any other provider's `_api_base`) at different
Expand Down Expand Up @@ -125,15 +125,15 @@ methods -- so a store that implements only those two still passes it.
include `BatchingCacheStoreContract` too, alongside `CacheStoreContract`,
once `#store` also implements `write_multi`.

Three stores ship with this gem: `TranslationDiff::MemoryCacheStore`, the
Three stores ship with this gem: `TranslationDiff::Stores::Memory`, the
default -- a bounded, in-process LRU, not thread-safe by design, evicting by
`cache_max_size` rather than by time; `TranslationDiff::RedisCacheStore`,
`cache_max_size` rather than by time; `TranslationDiff::Stores::Redis`,
built from `redis_url` when that is set, expiring entries after `cache_ttl`
and namespacing every key under `cache_namespace`; and
`TranslationDiff::ActiveRecordCacheStore`, opt-in, caching in the
`TranslationDiff::Stores::ActiveRecord`, 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
`Stores::Redis` takes anything answering to `#with` the way
`ConnectionPool` does, and yields anything `Redis::Namespace` accepts.

## `write_multi` is optional
Expand All @@ -145,25 +145,25 @@ from the batch; a store that does not is called once per sentence through
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
All three shipped stores implement it: `Stores::Memory` loops over the
pairs (there is no round trip to save in-process); `Stores::Redis`
pipelines the writes; `Stores::ActiveRecord` 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.**
- **No `write_multi` (the per-key path), and `Stores::Memory`'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
- **`Stores::Redis#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
- **`Stores::ActiveRecord#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.

Expand All @@ -177,7 +177,7 @@ paid for at the provider: `Translator#fill` rescues whatever error surfaces
here, logs it, fires a `cache_error` event (provider and error class only,
never the text -- see [Instrumentation](instrumentation.md)), and returns
the translation regardless. This holds for all three shapes and every
store, not only `ActiveRecordCacheStore` -- a `MemoryCacheStore` bug, a
store, not only `Stores::ActiveRecord` -- a `Stores::Memory` bug, a
dropped Redis connection, a SQL write blocked by a read-only replica (see
[Rails replica routing](sql-cache.md#rails-replica-routing)) all behave the
same way from the caller's side. What differs between the three shapes
Expand Down
22 changes: 11 additions & 11 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,13 @@ 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 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. It does not exempt this store from a Rails application's own read-replica routing; see [Rails replica routing](sql-cache.md#rails-replica-routing). See [SQL cache](sql-cache.md#active_record_base-a-second-database). |
| `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). |
| `cache_ttl` | `604_800` (one week) | Seconds an entry is kept before it expires. Read by `Stores::Redis` (a `SETEX`) and by `Stores::ActiveRecord` (written into each row's `expires_at`); `Stores::Memory` 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 `Stores::ActiveRecord#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 `Stores::Memory` keeps before evicting the least recently used one. |
| `cache_table_name` | `"translation_diff_translations"` | Table `Stores::ActiveRecord` 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 `RateLimiters::ActiveRecord` reads and writes. As above. |
| `active_record_base` | `nil` (`::ActiveRecord::Base`) | The class `Stores::ActiveRecord` and `RateLimiters::ActiveRecord` build their model from -- point this at a second database. It does not exempt this store from a Rails application's own read-replica routing; see [Rails replica routing](sql-cache.md#rails-replica-routing). See [SQL cache](sql-cache.md#active_record_base-a-second-database). |
| `cache_prune_probability` | `0.0` | Chance, per write, that `Stores::ActiveRecord` 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. |
Expand Down Expand Up @@ -148,7 +148,7 @@ makes a per-request `TranslationDiff.configure { |c| c.cache_namespace =
current_tenant }` safe: writing the same tenant on every request no longer
rebuilds the cache store on every request. Writing a genuinely *different*
value still rebuilds the store exactly as before, though, and if that store
is the default `MemoryCacheStore`, a rebuilt store is a fresh, empty Hash --
is the default `Stores::Memory`, a rebuilt store is a fresh, empty Hash --
its contents are gone, and whatever it held has to be paid for again at the
provider.

Expand All @@ -159,8 +159,8 @@ from has already built.

## Choosing the cache store

`cache` unset means "choose for me": `RedisCacheStore` when `redis_url` is
configured, `MemoryCacheStore` otherwise, so the library works before any
`cache` unset means "choose for me": `Stores::Redis` when `redis_url` is
configured, `Stores::Memory` otherwise, so the library works before any
infrastructure does. Set `cache` explicitly (`:redis`, `:memory`, or your own
object) to override that choice.

Expand Down Expand Up @@ -190,7 +190,7 @@ option *values* over, but deliberately not the collaborators already built
from them -- each context resolves its own provider, cache store, segmenter
and rate limiter from its own values, independently of whatever the
configuration it was copied from had already built. When `cache` is left
unset, that resolves to `MemoryCacheStore`, an in-process store, so a freshly
unset, that resolves to `Stores::Memory`, an in-process store, so a freshly
built context's store starts empty every time -- a short-lived, per-request
context therefore caches nothing across requests. Configure `redis_url` (or
assign one shared cache object explicitly) if contexts need to share a
Expand Down
17 changes: 8 additions & 9 deletions docs/contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ through its own registry, `TranslationDiff::RateLimiters` -- `:redis` and
and `Dispatcher#throttle` checks for that `nil` and skips rate limiting
entirely, so the common case costs nothing;
- otherwise the registered limiter named by `config.rate_limiter`, or
`TranslationDiff::RedisRateLimiter` when `rate_limiter` is left unset but
`TranslationDiff::RateLimiters::Redis` when `rate_limiter` is left unset but
`rate_limit` is set -- built from `rate_interval`, `cache_namespace`, and
either `redis_url` (`:redis`) or `active_record_base` and
`rate_limit_table_name` (`:active_record`; see [SQL cache](sql-cache.md)).
Expand All @@ -29,11 +29,10 @@ An object assigned to `rate_limiter` must implement:
def check(size); end
```

`TranslationDiff::RedisRateLimiter` raises
`TranslationDiff::RedisRateLimiter::RateLimitExceeded` when its threshold is
exceeded within its interval;
`TranslationDiff::ActiveRecordRateLimiter` raises its own
`RateLimitExceeded`, a distinct class under the same name. Both raise with a
Both shipped limiters raise `TranslationDiff::RateLimitExceeded` when the
threshold is exceeded within the interval -- one class whichever limiter is
configured, so switching from `:redis` to `:active_record` does not quietly
stop a `rescue` from matching. They raise with a
message naming the namespace, the threshold and the interval that were hit
(`"rate limit reached for translation-diff: 8000 characters per 60
seconds"`) -- never the text that tripped it. Neither `redis`
Expand All @@ -44,7 +43,7 @@ configures no `rate_limit` never needs it, and its absence raises
dependency either -- see [SQL cache](sql-cache.md#the-activerecord-version-floor).

**Upgrading to 3.1.0: re-validate your `rate_limit` threshold.** Before this
release, `RedisRateLimiter` never actually limited anything -- a signature
release, `RateLimiters::Redis` never actually limited anything -- a signature
mismatch with the `ratelimit` gem meant it recorded hits under a subject
`exceeded?` never read, so the threshold could never be reached. That bug
shipped in every release since `v1.0.2` (2023-02-16). If you have
Expand All @@ -64,8 +63,8 @@ Keep `rate_interval` within 5-600 seconds if you want the configured number
to be the enforced one.

Both the clamp above and the upgrade note before it are about
`RedisRateLimiter`, which delegates its bucketing to the `ratelimit` gem.
`ActiveRecordRateLimiter` owns its own bucketing instead, and its window is
`RateLimiters::Redis`, which delegates its bucketing to the `ratelimit` gem.
`RateLimiters::ActiveRecord` owns its own bucketing instead, and its window is
sliding rather than tumbling: buckets are `rate_interval / 12` seconds wide
(floored at 1 second), and a check sums every bucket touching the trailing
`rate_interval` seconds -- including the oldest one, which is only ever
Expand Down
Loading
Loading