Skip to content

feat: what a real application needed and the gem did not give it - #25

Merged
Halvanhelv merged 21 commits into
mainfrom
feat/bench-findings
Sep 11, 2026
Merged

feat: what a real application needed and the gem did not give it#25
Halvanhelv merged 21 commits into
mainfrom
feat/bench-findings

Conversation

@Halvanhelv

Copy link
Copy Markdown
Owner

Five things a real application needed and this gem did not give it, found by
building a Rails + Hotwire bench on top of it and translating a real HTML
article through live providers. Each one cost that application a workaround.

What the bench could not do

Correlate events. No payload said which translate a cache belonged
to, so the bench tagged Thread.current in its job and read it back in the
subscriber — which breaks the moment two translations share a thread. Every
event from one call now carries the same call_id.

Report a fully cached run. request and usage fire only when a
provider is reached, so a run served entirely from cache left a subscriber
holding nil where it wanted a number. translate now carries
characters: what the call considered, sent or not. request keeps its
narrower meaning — what one batch sent.

Ask what a call would cost. The bench's "this edit will send 1 sentence"
preview had to read Segment, Fragment and SentenceCache internals and
reimplement the cache key. TranslationDiff.preview answers it properly —
same segmenter, same key, same digest, no provider call, no write — and a
test pins its numbers to what the cache event then reports, so it cannot
drift from the thing it predicts.

Change a setting at runtime. Every collaborator was memoised and nothing
invalidated, so switching provider meant reset! and rebuilding the whole
configuration, discarding a Redis pool that had no reason to go. A write now
invalidates exactly what it affects, and a write that changes nothing
invalidates nothing.

What the article exposed

Code was translated as prose. Google turned jq '.meters' into
jq '.metros' inside a <pre><code> block, because nothing told the
pipeline that code is not language. pre and code join script and
style, the set is now config.opaque_elements, and the match is
case-insensitive — <PRE>, <CODE> and <STYLE> are what Word, Outlook
and older CMSes emit, and an uppercase <STYLE> was coming back translated
into Spanish.

A latent bug, found while testing the above. A notranslate span inside
an opaque element left the walker's depth counter one too high, and every
sentence after such a block silently stopped being sent for translation.

Upgrade consequences

Filed under Breaking in the CHANGELOG, not buried under Added:

  • cache keys change for a document containing pre or code — what is sent
    changed, so what is keyed changed;
  • a runtime cache_namespace or active_record_base change now moves the
    rate limiter as well as the store, instead of leaving the limiter counting
    under the old namespace;
  • a provider with a blank cache_key raises
    TranslationDiff::InvalidProviderError rather than
    TranslationDiff::Translator::Error — a sibling class, not a subclass.

And one honest remainder, documented where somebody tuning configuration will
read it: changing a cache option to a genuinely new value still rebuilds the
store, and if that store is the in-process memory one, its contents are gone.

Review found, and this branch fixed

The first pass at the opaque set matched case-sensitively, opaque_elements
was read only from the global configuration so a per-tenant Context was
ignored, the timeouts were classified as invalidating nothing while a
memoised provider builds its connection from them, and a no-op write still
cleared the memo — measurably re-billing a warm memory cache. Translator
and Previewer also still carried five verbatim copies of shared behaviour,
which is exactly why the missing opaque_elements: argument had to be
forgotten twice, and was; they now share CallPreparation.

745 runs on SQLite, 753 against real PostgreSQL, rubocop clean. Verified live
against Google and DeepL.

Google mangled `jq '.meters'` into `jq '.metros'` inside a live <pre><code>
block because nothing told the pipeline that code holds language, not prose.
pre and code now join script and style in Passage::Scanner's opaque set, and
the set is a plain config.opaque_elements option (default: script, style,
pre, code) so an application can widen or narrow it.

While testing the nesting case (a notranslate span inside an opaque element),
found and fixed a real bug: closing a protected element that itself raised
@opaque_depth (either by name, like <code class="notranslate">, or by
ancestry, like a notranslate span inside <code>) never gave that increment
back, because a protected element's own end_element takes the early-return
branch and never reaches the opaque decrement. Every sentence after such a
block silently stopped being sent for translation. attr now undoes the
bump right where it made it.

Metrics/ClassLength bumped from 100 to 110 for lib/**/* -- Configuration is a
flat table of declared options, and this one is a genuine addition, not
untidiness.
A subscriber receiving cache, request and usage events had no way to tell
which translate call they belonged to short of tagging Thread.current
themselves -- a workaround that breaks the moment two translations share
a thread. Translator now generates one opaque call_id per call and puts
it in translate, cache, request, rate_limit, usage and cache_error;
Dispatcher receives it rather than making its own, since it emits three
of the six.
request and usage only fire when a provider is reached, so a call served
entirely from cache left a subscriber with nil where it wanted a count.
translate now carries characters: every non-blank segment this call
looked at, hit or miss, computed once regardless of how the cache split
it into batches. request's own characters keeps its narrower meaning --
what one batch actually sent -- distinguished by which event it's on.
Configuration memoised provider_instance, cache_store, segmenter_instance,
rate_limiter_instance and redis_pool, and nothing ever cleared them. An
application wanting to switch provider at runtime had no way to do it
short of TranslationDiff.reset! and reconfiguring from scratch, which
also threw away the cache store and the Redis pool it had no reason to
touch.

Each option declaration now names, via `option ... invalidates:`, exactly
which memoised reader(s) it feeds; the writer `option` generates clears
only those ivars. Provider options get `invalidates: :provider_instance`
automatically from register_provider_options, so a provider's own option
names never need listing by hand. An option nobody classifies invalidates
nothing, the same as logger, instrumenter and the timeouts.

cache_ttl=, cache_namespace= and cache_prune_probability= are defined on
prepended modules and were setting their ivars directly, bypassing the
generic writer entirely; they now delegate to it through `super` so their
invalidation runs too.
… option table

The previous change raised the global ClassLength ceiling from 100 to 110
just to let Configuration fit, which makes the limit meaningless for
every other class. Put it back to 100.

Configuration's bulk was a flat table of `option` declarations -- exactly
the kind of thing that belongs in its own module rather than in the class
that implements the behaviour. Moved it to
TranslationDiff::Configuration::OptionTable, a plain [key, default,
invalidates] table with one line per option, applied to the class with
`OptionTable.declare_on(self)`. Configuration now fits under 100 lines
without an exclude or a raised ceiling.
… hits

Reuses translate's own Document/Passage segmenter, SentenceCache key, and
provider resolution rather than reimplementing them, so it cannot drift
from what translate actually does. Refuses cleanly when from: is nil and
the provider would need a paid detection request to answer.
Previewer had copied Translator's private provider-resolution branch
verbatim because it was private, leaving two copies of "which provider
does this call use" free to drift. Move it to TranslationDiff::Providers,
which already owns registration and the build/ensure_provider! guards,
as TranslationDiff::Providers.resolve -- the cache_key guard travels
with it, since a blank cache_key is a provider-validity failure, not a
translator- or previewer-specific one. Its raised error changes from
Translator::Error/Previewer::Error to the more precise
InvalidProviderError. This also drops Translator under
Metrics/ClassLength's 100-line limit (104 -> under 100).
Comments in this codebase stay at most one line; the extraction commit
left a few spanning two or three.
RedisRateLimiter.build and ActiveRecordRateLimiter.build both take
their namespace from config.cache_namespace, but the per-option
invalidation only mapped cache_namespace to cache_store. Changing the
namespace at runtime left a memoised rate_limiter_instance counting
under the old one, silently, and only in the Redis case (the
ActiveRecord limiter reads the DB row's namespace column fresh on
every check). The mapping was wrong, not the limiter's choice of
option: the limiter reading cache_namespace is the existing, intended
behaviour (see Configuration#copy's own comment on it), so cache_namespace
now also invalidates rate_limiter_instance. This is a behaviour change
for anyone who set cache_namespace after first touching the limiter,
expecting the old (buggy) memoised instance to stick: it now moves too.
…dation and preview

Five behaviours from building a real Rails + Hotwire app on this gem,
verified against the code and (where practical) live providers:
call_id on every event, translate's own characters, pre/code joining
the opaque element set, options invalidating only the collaborator
they feed, and TranslationDiff.preview.
Ox hands back element names exactly as written (:PRE, :Pre, :STYLE),
never lowercased, so Scanner#start_element compared a downcased opaque
set against an un-downcased name and uppercase/mixed-case pre, code,
script and style all leaked through to the provider. Fix the
comparison and the comment above protection? that had the same claim
backwards.
translate and preview are a matched pair at the top level, but Context
only carried translate, so a tenant's call could never be previewed.
Add preview, built the same way translate is, against the context's
own configuration.
…ewer

Both passed segmenter: and language: from the configuration a call is
actually using, but not opaque_elements, so Passage fell back to the
global TranslationDiff.config and a context's own opaque_elements was
silently ignored -- the one mechanism the docs recommend for a
per-tenant setting. Pass it through in both places.
…iewer

Previewer and Translator carried verbatim copies of passage,
same_language?, ensure_supported!, the "cannot detect the source
language" refusal and the SentenceCache construction -- the exact
duplication that let the opaque_elements argument be forgotten in
both places. Extract the shared parts into CallPreparation, included
by both, so the next omission cannot happen twice. Each class keeps
its own Error and does not merge into the other.
open_timeout, timeout and max_retries were classified as invalidating
nothing, but HTTPProvider#connection memoises a Faraday connection
built from all three, and the provider itself is memoised on
Configuration. Raising config.timeout from a settings screen changed
the value and nothing else until the process restarted. logger and
instrumenter really are read live and stay classified as they are.
Every write cleared its declared memos even when the value was
unchanged, so a per-request `configure { |c| c.cache_namespace =
tenant }` rebuilt the cache store on every request -- on the default
MemoryCacheStore a rebuild is a brand new empty Hash, so the
application paid the provider for its whole warm cache again. Compare
the raw stored value before writing and invalidating.

This does not cover every case: changing cache_ttl on a memory store
still drops a cache that never read that option, since cache_ttl is
still classified as invalidating cache_store.
Preview reported sendable_characters -- the numerator -- with no
denominator, on the same branch that added characters to the
translate event because a fully-cached call could not report its
size. Add characters, the same total translate reports, and test that
the two agree for the same call: the strongest available check.
…o-op-write fixes

Opaque element matching is now documented as case-insensitive; a
context's own config.opaque_elements is documented as reaching
Passage instead of falling back to the global value; the timeouts are
corrected from "rebuild nothing" to "rebuild the provider"; a same-
value write is documented as invalidating nothing, and what a
genuinely new cache option value still costs on MemoryCacheStore;
Context is documented as offering #preview alongside #translate;
Preview#characters is documented and tied back to the cache and
translate events it reconciles against.

Also fixes three standalone documentation defects: the instrumentation
event table contradicted its own page about translate/cache firing on
a fully-cached call; usage's characters was left out of the "two
events, different meaning" characters note; and two upgrade
consequences were filed as Added sub-bullets instead of under
Breaking, where a blank provider cache_key now raising
InvalidProviderError instead of Translator::Error has been added
alongside them.

README now shows TranslationDiff.preview in its code section.
@Halvanhelv
Halvanhelv merged commit 5311a5d into main Sep 11, 2026
5 checks passed
@Halvanhelv
Halvanhelv deleted the feat/bench-findings branch September 11, 2026 03:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant