Skip to content

Commit 5de9b40

Browse files
committed
fix(prod): audit hardening across batch budget, dispatch, cache, auth example, reader UX
fix(prod): audit hardening across batch budget, dispatch, cache, auth example, reader UX (#48)
2 parents 3925349 + cc2e11e commit 5de9b40

8 files changed

Lines changed: 135 additions & 31 deletions

File tree

.env.hub.example

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
BT_ENABLE_CWA=true
88
BT_ENABLE_KAVITA=true
99
BT_ROLE=api
10-
BT_AUTH_MODE=disabled
11-
BT_ALLOW_INSECURE_AUTH=true
10+
# Production auth: token mode with an explicit secret. For local development
11+
# only you may use BT_AUTH_MODE=disabled with BT_ALLOW_INSECURE_AUTH=true;
12+
# never expose a disabled-auth hub beyond localhost.
13+
BT_AUTH_MODE=token
14+
BT_API_TOKEN=change-me-to-a-long-random-secret
1215

1316
# --- Unified Ports ---
1417
PORT=8390
@@ -21,8 +24,10 @@ BT_LOCAL_URL=http://192.168.0.122:8082/v1/chat/completions
2124
LLM_MODEL=groq/openai/gpt-oss-120b
2225

2326
# --- High-Throughput Batching Economics ---
24-
# Sweet spot empirically tested for 0% segment loss and < 2s latency
25-
BT_BATCH_SIZE=20
27+
# Batch size and attempt budget must satisfy:
28+
# ceil(BT_MAX_BATCH_PARAGRAPHS / BT_BATCH_SIZE) + BT_BATCH_SIZE + 1
29+
# <= BT_REQUEST_MAX_ATTEMPTS (default 20). B=5/max=50 needs 16.
30+
BT_BATCH_SIZE=5
2631
BT_BATCH_SOURCE_TOKEN_BUDGET=1400
2732
BT_BATCH_MAX_TOKENS=3500
2833
BT_TIMEOUT=90

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Reject impossible batch/attempt configurations at startup: require
13+
`ceil(BT_MAX_BATCH_PARAGRAPHS / BT_BATCH_SIZE) <= BT_REQUEST_MAX_ATTEMPTS`
14+
so a max-size request can execute in the attempt budget.
15+
- Bound batch dispatch to a `max_concurrent` replenishment window instead of
16+
submitting every group upfront, cutting parked-thread pressure on the
17+
upstream semaphore under concurrent API requests.
18+
- Run cache retention (expiry + cap scan) on a write watermark instead of on
19+
every `put_many`, reducing SQLite WAL writer serialization on fresh batches.
20+
- Harden `.env.hub.example` to token auth and a budget-consistent batch size.
21+
- Make the reader prefetch toggle honest (enable rediscovers, disable drops
22+
queued background work), discard stale in-flight batch responses on
23+
page/language/mode change, add a compact narrow-viewport bar layout, and
24+
safeguard reader element extraction.
25+
1026
## [2.3.0] - 2026-09-03
1127

1228
### Added

cache.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ def __init__(
155155
self._pending_hits_total = 0
156156
self._pending_lock = threading.Lock()
157157
self._flush_lock = threading.Lock()
158+
self._writes_since_prune = 0
159+
self._prune_lock = threading.Lock()
158160
self._init_lock = threading.Lock()
159161
self._initialized = False
160162
self._prepare_directory()
@@ -558,8 +560,20 @@ def put_many(
558560
last_accessed_at = excluded.last_accessed_at""",
559561
rows,
560562
)
561-
self._delete_expired(conn)
562-
self._enforce_cap(conn)
563+
# Retention (expiry + cap ORDER BY/OFFSET scan) runs on a
564+
# watermark, not on every write: the scan serializes the WAL
565+
# writer and adds p95 on every fresh batch once near capacity.
566+
# The watermark scales down for tiny test caps so cap behavior
567+
# stays observable in unit tests.
568+
with self._prune_lock:
569+
self._writes_since_prune += len(rows)
570+
threshold = min(1000, self.max_entries)
571+
due = self._writes_since_prune >= threshold
572+
if due:
573+
self._writes_since_prune = 0
574+
if due:
575+
self._delete_expired(conn)
576+
self._enforce_cap(conn)
563577
conn.commit()
564578
except Exception:
565579
conn.rollback()

server.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
single_cache_contract, singleflight_stats, BatchRecoveryTracker,
3636
RECOVERY_METRIC_NAMES,
3737
estimate_source_tokens, provider_call_stats, BT_BATCH_SIZE,
38+
BT_REQUEST_MAX_ATTEMPTS,
3839
_reset_provider_call_stats_for_tests,
3940
provider_policy,
4041
initialize_provider_configuration,
@@ -133,6 +134,14 @@ def _cache_lookup(
133134
raise ValueError(
134135
"BT_BATCH_SIZE must not exceed BT_MAX_BATCH_PARAGRAPHS"
135136
)
137+
# A max-size request must be executable within the attempt budget even
138+
# in the clean path with no retries.
139+
_min_attempts = -(-BT_MAX_BATCH_PARAGRAPHS // BT_BATCH_SIZE)
140+
if BT_REQUEST_MAX_ATTEMPTS < _min_attempts:
141+
raise ValueError(
142+
"BT_REQUEST_MAX_ATTEMPTS too small for BT_MAX_BATCH_PARAGRAPHS / "
143+
f"BT_BATCH_SIZE: need at least {_min_attempts} attempts"
144+
)
136145
BT_MAX_PARAGRAPH_CHARS = int(os.environ.get("BT_MAX_PARAGRAPH_CHARS", "8000"))
137146
BT_CACHE_SCOPE_MAX_CHARS = int(os.environ.get("BT_CACHE_SCOPE_MAX_CHARS", "512"))
138147

static/translator.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@
6666
transform: translateX(-50%);
6767
height: auto;
6868
max-height: 48px;
69+
max-width: calc(100vw - 20px);
70+
touch-action: none;
6971
z-index: 2147483000;
7072
display: flex;
7173
align-items: center;
@@ -314,3 +316,11 @@ button.bt-menu-item:focus-visible { outline: 2px solid var(--bt-bilingual); }
314316
#bt-toast.bt-toast-visible { opacity: 1; transform: translateX(-50%) translateY(0); }
315317

316318
@media print { #bt-bar, #bt-toast { display: none !important; } }
319+
320+
/* ── Compact layout for narrow viewports ──────────────────────────── */
321+
@media (max-width: 420px) {
322+
#bt-bar { gap: 4px; padding: 5px 6px; font-size: 12px; }
323+
#bt-status { width: 64px; }
324+
#bt-toggle-label { max-width: 64px; overflow: hidden; text-overflow: ellipsis; }
325+
#bt-lang { max-width: 96px; }
326+
}

static/translator.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -799,7 +799,12 @@
799799
prefetchEnabled = !prefetchEnabled;
800800
localStorage.setItem('bt_prefetch', prefetchEnabled ? '1' : '0');
801801
buildMenu();
802-
if (prefetchEnabled && translationMode !== 'off') triggerPrefetch();
802+
if (!prefetchEnabled) {
803+
prefetchQueue = [];
804+
refreshStatus();
805+
} else if (translationMode !== 'off') {
806+
scheduleTranslate('prefetch_enabled', { immediate: true, forceRediscover: true });
807+
}
803808
} else if (action === 'cloud-fallback') {
804809
allowCloudFallback = !allowCloudFallback;
805810
buildMenu();
@@ -1425,6 +1430,7 @@
14251430
return true;
14261431
});
14271432
prefetchQueue = prefetchQueue.filter(x => {
1433+
if (!prefetchEnabled) return false;
14281434
if (x.gen !== generation || translatedParagraphs[x.hash] || seenHash.has(x.hash)) return false;
14291435
seenHash.add(x.hash);
14301436
return true;
@@ -1502,6 +1508,15 @@
15021508

15031509
inflightCount = 0;
15041510

1511+
// Stale-response guard: page/language/mode may have changed
1512+
// while the request was in flight. Never let an old batch
1513+
// pollute cache, counters, or DOM.
1514+
if ((batch.length ? batch[0].gen : generation) !== generation
1515+
|| translationMode === 'off' || !readerRouteActive) {
1516+
refreshStatus();
1517+
continue;
1518+
}
1519+
15051520
if (data && data.error === 'aborted') {
15061521
// Deliberate cancel (mode/language/page change) — the items
15071522
// belong to a stale generation and get filtered next pass.

tests/python/test_provider_budget.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1323,6 +1323,31 @@ def test_api_startup_rejects_browser_batch_above_request_limit(self):
13231323
result.stderr,
13241324
)
13251325

1326+
def test_api_startup_rejects_attempt_budget_below_worst_case(self):
1327+
env = os.environ.copy()
1328+
env.update({
1329+
"BT_AUTH_MODE": "disabled",
1330+
"BT_ALLOW_INSECURE_AUTH": "true",
1331+
"BT_BATCH_SIZE": "2",
1332+
"BT_MAX_BATCH_PARAGRAPHS": "50",
1333+
"BT_REQUEST_MAX_ATTEMPTS": "20",
1334+
})
1335+
1336+
result = subprocess.run(
1337+
[sys.executable, "-c", "import server"],
1338+
cwd=ROOT,
1339+
env=env,
1340+
capture_output=True,
1341+
text=True,
1342+
check=False,
1343+
)
1344+
1345+
self.assertNotEqual(result.returncode, 0)
1346+
self.assertIn(
1347+
"BT_REQUEST_MAX_ATTEMPTS too small",
1348+
result.stderr,
1349+
)
1350+
13261351
def test_recommended_compose_pins_safe_budget_defaults(self):
13271352
compose = (ROOT / "docker-compose.yml").read_text()
13281353
expected = {

translator.py

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import time
2525
import logging
2626
import requests
27-
from concurrent.futures import ThreadPoolExecutor, as_completed
27+
from concurrent.futures import ThreadPoolExecutor, as_completed, wait, FIRST_COMPLETED
2828
from dataclasses import dataclass
2929
from requests.adapters import HTTPAdapter
3030
from typing import Callable, Literal, Optional
@@ -2114,31 +2114,41 @@ def _do_group(idxs):
21142114
return idxs, translations
21152115

21162116
executor = ThreadPoolExecutor(max_workers=max_concurrent)
2117-
futures = [executor.submit(_do_group, g) for g in groups]
2117+
pending = list(groups)
2118+
futures = {}
21182119
try:
2119-
for future in as_completed(futures):
2120-
try:
2121-
idxs, translations = future.result()
2122-
except WorkBudgetExceeded as exc:
2123-
if exc.reason == "cancelled":
2124-
with fatal_lock:
2125-
protocol_error = fatal_protocol_error[0]
2126-
if protocol_error is not None:
2127-
raise protocol_error
2128-
raise
2129-
for j, idx in enumerate(idxs):
2130-
# Each entry carries the provider that ACTUALLY served it
2131-
# (the fallback provider when the primary failed).
2132-
results[idx] = (
2133-
translations[j]
2134-
if j < len(translations)
2135-
else BatchTranslationItem(
2136-
"[TRANSLATION ERROR: missing segment]",
2137-
"",
2138-
False,
2139-
"failed",
2120+
# Bounded window: never hold more than max_concurrent futures at once.
2121+
# Previously every group was submitted upfront, so 8 concurrent API
2122+
# requests could park dozens of threads on the 2s upstream semaphore.
2123+
while pending or futures:
2124+
while pending and len(futures) < max_concurrent:
2125+
g = pending.pop(0)
2126+
futures[executor.submit(_do_group, g)] = g
2127+
done, _ = wait(list(futures), return_when=FIRST_COMPLETED)
2128+
for future in done:
2129+
idxs = futures.pop(future)
2130+
try:
2131+
idxs, translations = future.result()
2132+
except WorkBudgetExceeded as exc:
2133+
if exc.reason == "cancelled":
2134+
with fatal_lock:
2135+
protocol_error = fatal_protocol_error[0]
2136+
if protocol_error is not None:
2137+
raise protocol_error
2138+
raise
2139+
for j, idx in enumerate(idxs):
2140+
# Each entry carries the provider that ACTUALLY served it
2141+
# (the fallback provider when the primary failed).
2142+
results[idx] = (
2143+
translations[j]
2144+
if j < len(translations)
2145+
else BatchTranslationItem(
2146+
"[TRANSLATION ERROR: missing segment]",
2147+
"",
2148+
False,
2149+
"failed",
2150+
)
21402151
)
2141-
)
21422152
except (SegmentProtocolError, WorkBudgetExceeded):
21432153
for future in futures:
21442154
future.cancel()

0 commit comments

Comments
 (0)