mirror of
https://github.com/chmouel/liseur-sync.git
synced 2026-10-19 03:17:09 +00:00
fix: bound the translation cache by what a row really costs
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:
parent
e1d0ce7699
commit
63151d6aeb
9 changed files with 40 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1085,6 +1085,7 @@ paths:
|
|||
properties:
|
||||
translations:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 500
|
||||
items: { $ref: "#/components/schemas/WorkTranslationInput" }
|
||||
responses:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue