fix: bound the translation cache by what a row really costs
Some checks failed
test / test (push) Has been cancelled
test / lint-openapi (push) Has been cancelled
build / binary (push) Has been cancelled
build / container (push) Has been cancelled

The per-user budget counted only the translated text, so rows of tiny
text spent almost none of it and the row count grew without bound.
Each entry now spends its hash and its text in both the total and the
eviction measure. The per-user stamp table also gains the cascading
user reference its sibling tables carry, and the OpenAPI push schema
marks the translations array as non-empty, as the route already
refuses it.
This commit is contained in:
Chmouel Boudjnah 2026-10-13 06:21:15 +02:00
parent e1d0ce7699
commit 63151d6aeb
9 changed files with 40 additions and 22 deletions

View file

@ -408,7 +408,8 @@ where an identical push writes nothing at all, and the pull is a
per-work delta feed ordered by `(updated_at, key)` behind an opaque
cursor. `unknown_work` is the refusal ops already use, with the same
recovery. The cache is bounded in bytes per user — measured in what is
stored, not in characters — and evicts its oldest rows first; it is a
stored, each entry spending its hash and its text, not in characters —
and evicts its oldest rows first; it is a
cache, not reading history, and the per-book "left translated" mode
stays on the device as ADR-0050 keeps every other preference. Server
stamps only ever move forward while the database remembers the last, so

View file

@ -67,8 +67,10 @@ same shape, a bounded atomic batch. Per value the bound is
`ops.translation_max_value_bytes` (8 KiB, above the 2,000 characters
the client itself refuses to translate), one pull page is
`ops.translation_max_pull` (500), and the whole cache is bounded in
bytes per user by `ops.translation_budget_bytes` (32 MiB). Over
budget, the oldest-`updated_at` rows are evicted first, inside the
bytes per user by `ops.translation_budget_bytes` (32 MiB) — each entry
spending its hash and its text, so a row of tiny text still spends its
share and the row count stays bounded too. Over budget, the
oldest-`updated_at` rows are evicted first, inside the
push transaction — a cache, not an archive, and what leaves is what a
pull can fetch again anyway.

View file

@ -753,8 +753,9 @@ because a batch lands under one timestamp and a cursor cutting at the
does not parse is `400`; drop yours and pull from the beginning —
re-reading costs requests, skipping rows loses them. The whole cache
is bounded in bytes per user (`ops.translation_budget_bytes`, 32 MiB
by default) and evicts its oldest-updated rows first, so a row you
pulled may be gone later: translate the sentence again.
by default, each entry counting its hash and its text) and evicts its
oldest-updated rows first, so a row you pulled may be gone later:
translate the sentence again.
## Reading sessions

View file

@ -1085,6 +1085,7 @@ paths:
properties:
translations:
type: array
minItems: 1
maxItems: 500
items: { $ref: "#/components/schemas/WorkTranslationInput" }
responses:

View file

@ -759,7 +759,7 @@ CREATE INDEX work_translations_eviction ON work_translations(user_id, updated_at
// the per-user high-water mark a translation stamp is allocated above.
const workTranslationStamps = `
CREATE TABLE work_translation_stamps (
user_id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
high TIMESTAMPTZ NOT NULL
);
`

View file

@ -144,9 +144,11 @@ func evictWorkTranslationsTx(ctx context.Context, tx *sql.Tx, userID string, bud
for {
var total sql.NullInt64
// The budget is bytes and so is the measure: LENGTH counts
// characters, OCTET_LENGTH what is actually stored.
// characters, OCTET_LENGTH what is actually stored. The hash
// rides along in the measure so a row of tiny text still spends
// its share and the row count stays bounded too.
if err := tx.QueryRowContext(ctx, q(
`SELECT SUM(OCTET_LENGTH(translation)) FROM work_translations WHERE user_id = ?`),
`SELECT SUM(OCTET_LENGTH(key) + OCTET_LENGTH(translation)) FROM work_translations WHERE user_id = ?`),
userID).Scan(&total); err != nil {
return err
}
@ -155,7 +157,7 @@ func evictWorkTranslationsTx(ctx context.Context, tx *sql.Tx, userID string, bud
return nil
}
rows, err := tx.QueryContext(ctx, q(
`SELECT ctid::text, OCTET_LENGTH(translation) FROM work_translations
`SELECT ctid::text, OCTET_LENGTH(key) + OCTET_LENGTH(translation) FROM work_translations
WHERE user_id = ? ORDER BY updated_at, key LIMIT 100`),
userID)
if err != nil {

View file

@ -1184,7 +1184,7 @@ CREATE INDEX work_translations_eviction ON work_translations(user_id, updated_at
// pull's cursor for good.
const workTranslationStamps = `
CREATE TABLE work_translation_stamps (
user_id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
high TEXT NOT NULL
);
`

View file

@ -146,9 +146,11 @@ func evictWorkTranslationsTx(ctx context.Context, tx *sql.Tx, userID string, bud
// The budget is bytes and so is the measure: LENGTH counts
// characters and stops at an embedded NUL, which a value full
// of them would spend none of — CAST to BLOB counts what is
// actually stored.
// actually stored. The hash rides along in the measure so a row
// of tiny text still spends its share and the row count stays
// bounded too.
if err := tx.QueryRowContext(ctx,
`SELECT SUM(LENGTH(CAST(translation AS BLOB))) FROM work_translations WHERE user_id = ?`,
`SELECT SUM(LENGTH(CAST(key AS BLOB)) + LENGTH(CAST(translation AS BLOB))) FROM work_translations WHERE user_id = ?`,
userID).Scan(&total); err != nil {
return err
}
@ -162,7 +164,7 @@ func evictWorkTranslationsTx(ctx context.Context, tx *sql.Tx, userID string, bud
// query per row. The key rides along in the ordering so rows
// sharing a timestamp leave in a deterministic order.
rows, err := tx.QueryContext(ctx,
`SELECT rowid, LENGTH(CAST(translation AS BLOB)) FROM work_translations
`SELECT rowid, LENGTH(CAST(key AS BLOB)) + LENGTH(CAST(translation AS BLOB)) FROM work_translations
WHERE user_id = ? ORDER BY updated_at, key LIMIT 100`,
userID)
if err != nil {

View file

@ -239,8 +239,10 @@ func testWorkTranslationsBudget(t *testing.T, open OpenFunc) {
}
}
// First two rows under a budget that fits both.
push(12, store.WorkTranslationWrite{Key: trKey("e1"), Translation: "123456"},
// First two rows under a budget that fits both: 64 hash bytes
// plus six text bytes each, seventy a row, one hundred and forty
// the pair.
push(140, store.WorkTranslationWrite{Key: trKey("e1"), Translation: "123456"},
store.WorkTranslationWrite{Key: trKey("e2"), Translation: "123456"})
if all := trAll(t, s, u.ID, w.ID, 10); len(all) != 2 {
t.Fatalf("before eviction: %+v", all)
@ -248,7 +250,7 @@ func testWorkTranslationsBudget(t *testing.T, open OpenFunc) {
// A third row over the budget evicts the oldest-updated row — the
// first one — and keeps the newer two.
push(12, store.WorkTranslationWrite{Key: trKey("e3"), Translation: "123456"})
push(140, store.WorkTranslationWrite{Key: trKey("e3"), Translation: "123456"})
all := trAll(t, s, u.ID, w.ID, 10)
if len(all) != 2 {
t.Fatalf("after eviction: %+v", all)
@ -270,21 +272,28 @@ func testWorkTranslationsBudgetCountsBytes(t *testing.T, open OpenFunc) {
u := MkUser(t, s, "tr-bytes")
w := MkWork(t, s, u, "trw6", "trsha6")
// Three two-byte characters: six bytes, three characters.
// Three two-byte characters: the row measures sixty-four hash
// bytes plus six value bytes, seventy in all, sixty-seven counted
// in characters. A budget of sixty-eight fits the characters but
// not the bytes, so the row does not survive its own push; one of
// seventy holds it.
value := "ééé"
push := func(items ...store.WorkTranslationWrite) {
push := func(budget int64, items ...store.WorkTranslationWrite) {
t.Helper()
if err := s.PushWorkTranslations(ctx, u.ID, w.ID, "phone", items, 6); err != nil {
if err := s.PushWorkTranslations(ctx, u.ID, w.ID, "phone", items, budget); err != nil {
t.Fatal(err)
}
}
push(store.WorkTranslationWrite{Key: trKey("b1"), Translation: value})
push(store.WorkTranslationWrite{Key: trKey("b2"), Translation: value})
push(68, store.WorkTranslationWrite{Key: trKey("b1"), Translation: value})
if all := trAll(t, s, u.ID, w.ID, 10); len(all) != 0 {
t.Fatalf("a character-counted budget kept the row: %+v", all)
}
push(70, store.WorkTranslationWrite{Key: trKey("b2"), Translation: value})
all := trAll(t, s, u.ID, w.ID, 10)
if len(all) != 1 || all[0].Key != trKey("b2") {
t.Fatalf("a character-counted budget kept both rows: %+v", all)
t.Fatalf("a byte-counted budget lost the row: %+v", all)
}
}