Translation dispatcher: single-item jobs, capability Hello, wanted-langs setting
This commit is contained in:
+94
-56
@@ -410,11 +410,11 @@ _render_gen = 0
|
|||||||
|
|
||||||
def _invalidate_pages() -> None:
|
def _invalidate_pages() -> None:
|
||||||
"""Drop cached page bodies and bump the render generation (ETags);
|
"""Drop cached page bodies and bump the render generation (ETags);
|
||||||
any content change also pushes translation-job deltas to translators."""
|
any content change also re-runs translation dispatch."""
|
||||||
global _render_gen
|
global _render_gen
|
||||||
_render_gen += 1
|
_render_gen += 1
|
||||||
_cached_body.cache_clear()
|
_cached_body.cache_clear()
|
||||||
_schedule_translator_notify()
|
_schedule_translation_dispatch()
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=128)
|
@lru_cache(maxsize=128)
|
||||||
@@ -622,7 +622,8 @@ async def update_structure(op: StructureOp) -> None:
|
|||||||
async def get_settings() -> dict:
|
async def get_settings() -> dict:
|
||||||
"""Site-wide settings (brand, theme, custom CSS and favicon URL), plus
|
"""Site-wide settings (brand, theme, custom CSS and favicon URL), plus
|
||||||
the themes, banner designs and user fonts available on disk for the
|
the themes, banner designs and user fonts available on disk for the
|
||||||
selectors and the translator service key (for the /_translate socket)."""
|
selectors, the translator service key and the wanted translation
|
||||||
|
languages (for the /_translate socket)."""
|
||||||
return {
|
return {
|
||||||
"brand": data.brand,
|
"brand": data.brand,
|
||||||
"brand_html": data.brand_html,
|
"brand_html": data.brand_html,
|
||||||
@@ -635,6 +636,7 @@ async def get_settings() -> dict:
|
|||||||
"transition": data.transition,
|
"transition": data.transition,
|
||||||
"transitions": views._transition_names(),
|
"transitions": views._transition_names(),
|
||||||
"translate_key": data.translate_key,
|
"translate_key": data.translate_key,
|
||||||
|
"translate_langs": sorted(data.translate_langs),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -646,6 +648,7 @@ class SettingsIn(BaseModel):
|
|||||||
custom_css: str
|
custom_css: str
|
||||||
brand_html: str = ""
|
brand_html: str = ""
|
||||||
transition: str = "cube"
|
transition: str = "cube"
|
||||||
|
translate_langs: list[str] | None = None # None keeps the current set
|
||||||
|
|
||||||
|
|
||||||
@app.put("/_api/settings", status_code=204)
|
@app.put("/_api/settings", status_code=204)
|
||||||
@@ -657,6 +660,12 @@ async def put_settings(settings: SettingsIn) -> None:
|
|||||||
data.theme = settings.theme
|
data.theme = settings.theme
|
||||||
data.custom_css = settings.custom_css
|
data.custom_css = settings.custom_css
|
||||||
data.transition = settings.transition
|
data.transition = settings.transition
|
||||||
|
if settings.translate_langs is not None:
|
||||||
|
data.translate_langs = {
|
||||||
|
tag: True
|
||||||
|
for lang in settings.translate_langs
|
||||||
|
if (tag := i18n.base_tag(lang)) and tag != i18n.ORIGINAL_LANGUAGE
|
||||||
|
}
|
||||||
_invalidate_pages()
|
_invalidate_pages()
|
||||||
|
|
||||||
|
|
||||||
@@ -973,28 +982,37 @@ async def delete_page(path: str) -> None:
|
|||||||
_invalidate_pages()
|
_invalidate_pages()
|
||||||
|
|
||||||
|
|
||||||
|
# WebSocket API for external translation services (not under /_api: it is keyed
|
||||||
|
# with Data.translate_key instead of the SSO forward-auth). The server is a
|
||||||
|
# dispatcher: one single-item job at a time per connection, offered in the
|
||||||
|
# intersection of the wanted languages (Data.translate_langs) and the
|
||||||
|
# connection's announced capabilities. Results are matched to content by
|
||||||
|
# chunk key alone.
|
||||||
class _TranslatorState:
|
class _TranslatorState:
|
||||||
"""One connected translator socket: announced languages and the
|
"""One connected translator socket: the language codes it announced as
|
||||||
(lang, chunk-key) pairs already sent and still outstanding on it.
|
capabilities (Hello) and the (lang, chunk-key) job currently in flight
|
||||||
|
on it — one at a time, the next is sent only after its Result.
|
||||||
|
|
||||||
Per-connection only: a reconnecting client re-receives everything
|
Per-connection only: in-flight lives solely here, so on disconnect the
|
||||||
pending for its languages (its Hello triggers a full Job push)."""
|
item simply becomes pending again and is re-offered to any free capable
|
||||||
|
connection."""
|
||||||
|
|
||||||
def __init__(self, langs: set[str]) -> None:
|
def __init__(self, capable: set[str]) -> None:
|
||||||
self.langs = langs
|
self.capable = capable
|
||||||
self.outstanding: set[tuple[str, bytes]] = set()
|
self.inflight: tuple[str, bytes] | None = None
|
||||||
|
|
||||||
|
|
||||||
#: Connected translator sockets and their per-connection state.
|
#: Connected translator sockets and their per-connection state.
|
||||||
_translator_clients: dict[WebSocket, _TranslatorState] = {}
|
_translator_clients: dict[WebSocket, _TranslatorState] = {}
|
||||||
|
|
||||||
|
|
||||||
def _schedule_translator_notify() -> None:
|
def _schedule_translation_dispatch() -> None:
|
||||||
"""Schedule a delta Job push to connected translators, if any.
|
"""Schedule a dispatch pass, if any translator is connected.
|
||||||
|
|
||||||
The hook is _invalidate_pages (sync, called inside transactions): the
|
The content-change hook is _invalidate_pages (sync, called inside
|
||||||
task first runs once the current coroutine awaits again, i.e. after the
|
transactions): the task first runs once the current coroutine awaits
|
||||||
transaction has committed. No-op without a running loop (CLI use).
|
again, i.e. after the transaction has committed. No-op without a
|
||||||
|
running loop (CLI use).
|
||||||
"""
|
"""
|
||||||
if not _translator_clients:
|
if not _translator_clients:
|
||||||
return
|
return
|
||||||
@@ -1002,28 +1020,49 @@ def _schedule_translator_notify() -> None:
|
|||||||
asyncio.get_running_loop()
|
asyncio.get_running_loop()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
return
|
return
|
||||||
asyncio.create_task(_notify_translators())
|
asyncio.create_task(_dispatch_translations())
|
||||||
|
|
||||||
|
|
||||||
async def _notify_translators() -> None:
|
async def _dispatch_translations() -> None:
|
||||||
"""Push newly-pending items to every connected translator, deltas only:
|
"""Offer one pending item to every free capable connection.
|
||||||
items never sent on that connection and still untranslated."""
|
|
||||||
|
Runs on every relevant event: Hello, Result, disconnect and content
|
||||||
|
change (via _invalidate_pages). A connection with no wanted ∩ capable
|
||||||
|
overlap simply stays idle. Pending is derived from the trans store
|
||||||
|
(translate.pending_items) minus the items in flight on any connection.
|
||||||
|
"""
|
||||||
|
wanted = {
|
||||||
|
tag
|
||||||
|
for lang in data.translate_langs
|
||||||
|
if (tag := i18n.base_tag(lang)) and tag != i18n.ORIGINAL_LANGUAGE
|
||||||
|
}
|
||||||
|
if not wanted:
|
||||||
|
return
|
||||||
for ws, state in list(_translator_clients.items()):
|
for ws, state in list(_translator_clients.items()):
|
||||||
for lang in sorted(state.langs):
|
if state.inflight is not None:
|
||||||
items = [
|
continue
|
||||||
item
|
langs = wanted & state.capable
|
||||||
for item in translate.pending_items(data, lang)
|
if not langs:
|
||||||
if (lang, item.key) not in state.outstanding
|
continue
|
||||||
]
|
inflight = {s.inflight for s in _translator_clients.values() if s.inflight}
|
||||||
if not items:
|
job = None
|
||||||
continue
|
for lang in sorted(langs):
|
||||||
state.outstanding |= {(lang, item.key) for item in items}
|
for item in translate.pending_items(data, lang):
|
||||||
try:
|
if (lang, item.key) not in inflight:
|
||||||
job = translate.Job(lang=lang, items=items)
|
job = translate.Job(
|
||||||
await ws.send_text(msgspec.json.encode(job).decode())
|
lang=lang, key=item.key, text=item.text,
|
||||||
except Exception: # send failed: the receive loop cleans up
|
path=item.path, kind=item.kind,
|
||||||
_translator_clients.pop(ws, None)
|
)
|
||||||
|
break
|
||||||
|
if job is not None:
|
||||||
break
|
break
|
||||||
|
if job is None:
|
||||||
|
continue
|
||||||
|
state.inflight = (job.lang, job.key) # before the await: no double-assign
|
||||||
|
try:
|
||||||
|
await ws.send_text(msgspec.json.encode(job).decode())
|
||||||
|
except Exception: # send failed: the receive loop cleans up
|
||||||
|
_translator_clients.pop(ws, None)
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/_translate/{clientkey}")
|
@app.websocket("/_translate/{clientkey}")
|
||||||
@@ -1037,12 +1076,13 @@ async def translate_ws(ws: WebSocket, clientkey: str) -> None:
|
|||||||
before accept makes Starlette answer HTTP 403.
|
before accept makes Starlette answer HTTP 403.
|
||||||
|
|
||||||
Protocol (JSON frames, msgspec structs in translate.py): the client
|
Protocol (JSON frames, msgspec structs in translate.py): the client
|
||||||
opens with Hello(langs) (normalized to base subtags; "en"/empty
|
opens with Hello(langs) announcing its CAPABILITIES — the language
|
||||||
dropped); the server pushes Job(lang, items) — on connect everything
|
codes its model can produce (normalized to base subtags; "en"/empty
|
||||||
pending per language, afterwards deltas on content change — and the
|
dropped). The dispatcher sends one Job(lang, key, text, path, kind)
|
||||||
client answers with Result(lang, items) batches. A Result for an
|
at a time and waits for the matching Result(lang, key, text) before
|
||||||
unannounced or invalid language, or any malformed frame, closes the
|
offering the next. A Result without an in-flight job or with a
|
||||||
socket with a protocol error.
|
different (lang, key), a duplicate Hello, or any malformed frame
|
||||||
|
closes the socket with a protocol error.
|
||||||
"""
|
"""
|
||||||
if not data.translate_key or clientkey != data.translate_key:
|
if not data.translate_key or clientkey != data.translate_key:
|
||||||
await ws.close(code=1008) # policy violation; pre-accept = HTTP 403
|
await ws.close(code=1008) # policy violation; pre-accept = HTTP 403
|
||||||
@@ -1061,38 +1101,36 @@ async def translate_ws(ws: WebSocket, clientkey: str) -> None:
|
|||||||
if state is not None: # one Hello per connection
|
if state is not None: # one Hello per connection
|
||||||
await ws.close(code=1002)
|
await ws.close(code=1002)
|
||||||
return
|
return
|
||||||
langs = {
|
state = _TranslatorState({
|
||||||
tag
|
tag
|
||||||
for lang in msg.langs
|
for lang in msg.langs
|
||||||
if (tag := i18n.base_tag(lang)) and tag != i18n.ORIGINAL_LANGUAGE
|
if (tag := i18n.base_tag(lang)) and tag != i18n.ORIGINAL_LANGUAGE
|
||||||
}
|
})
|
||||||
state = _TranslatorState(langs)
|
|
||||||
_translator_clients[ws] = state
|
_translator_clients[ws] = state
|
||||||
for lang in sorted(langs):
|
_schedule_translation_dispatch()
|
||||||
items = translate.pending_items(data, lang)
|
|
||||||
if not items:
|
|
||||||
continue
|
|
||||||
state.outstanding |= {(lang, item.key) for item in items}
|
|
||||||
job = translate.Job(lang=lang, items=items)
|
|
||||||
await ws.send_text(msgspec.json.encode(job).decode())
|
|
||||||
else: # translate.Result
|
else: # translate.Result
|
||||||
lang = i18n.base_tag(msg.lang)
|
lang = i18n.base_tag(msg.lang)
|
||||||
if (
|
if (
|
||||||
state is None # results before Hello
|
state is None # results before Hello
|
||||||
or not lang
|
or state.inflight is None # no job in flight
|
||||||
or lang == i18n.ORIGINAL_LANGUAGE
|
or (lang, msg.key) != state.inflight # wrong job
|
||||||
or lang not in state.langs # not announced in Hello
|
|
||||||
):
|
):
|
||||||
await ws.close(code=1002)
|
await ws.close(code=1002)
|
||||||
return
|
return
|
||||||
with kanta.transaction("translator results", extra=lang):
|
with kanta.transaction("translator results", extra=lang):
|
||||||
translate.store_results(data, lang, msg.items)
|
paths = translate.store_results(
|
||||||
_invalidate_pages()
|
data, lang, [translate.TransResult(key=msg.key, text=msg.text)]
|
||||||
state.outstanding -= {(lang, item.key) for item in msg.items}
|
)
|
||||||
|
_invalidate_pages() # schedules the next dispatch
|
||||||
|
state.inflight = None
|
||||||
|
if paths:
|
||||||
|
print(f"[{lang}] now available for {len(paths)} page(s): {', '.join(sorted(paths))}")
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
_translator_clients.pop(ws, None)
|
if _translator_clients.pop(ws, None) is not None:
|
||||||
|
# The in-flight item (if any) is pending again; offer it around.
|
||||||
|
_schedule_translation_dispatch()
|
||||||
|
|
||||||
|
|
||||||
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ class Data(msgspec.Struct):
|
|||||||
#: the external forward-auth does not cover that route). Generated
|
#: the external forward-auth does not cover that route). Generated
|
||||||
#: lazily at startup when empty (see lifespan in app.py).
|
#: lazily at startup when empty (see lifespan in app.py).
|
||||||
translate_key: str = ""
|
translate_key: str = ""
|
||||||
|
#: Wanted target languages for the translator service (presence-keys,
|
||||||
|
#: value always True). The dispatcher offers jobs only in the
|
||||||
|
#: intersection of these and a connection's announced capabilities.
|
||||||
|
#: Read/set via /_api/settings (no editing UI yet).
|
||||||
|
translate_langs: dict[str, bool] = {}
|
||||||
#: All original-language page text, content-addressed:
|
#: All original-language page text, content-addressed:
|
||||||
#: chunk_key (9 bytes; base64 at the JSON level) -> Markdown chunk.
|
#: chunk_key (9 bytes; base64 at the JSON level) -> Markdown chunk.
|
||||||
#: Shared by every article.
|
#: Shared by every article.
|
||||||
|
|||||||
+23
-11
@@ -5,8 +5,9 @@ The external machine-translation service connects over WebSocket
|
|||||||
the tagged msgspec structs below (``bytes`` fields ride as base64 — no
|
the tagged msgspec structs below (``bytes`` fields ride as base64 — no
|
||||||
manual encoding anywhere). This module holds the message structs plus the
|
manual encoding anywhere). This module holds the message structs plus the
|
||||||
two computations shared by the WS handler: which fragments are pending for
|
two computations shared by the WS handler: which fragments are pending for
|
||||||
a language (``pending_items``) and storing a batch of results
|
a language (``pending_items``) and storing a result (``store_results``).
|
||||||
(``store_results``).
|
The dispatcher itself (one job at a time per connection, wanted ∩ capable
|
||||||
|
language matching, requeue on disconnect) lives in app.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
@@ -16,7 +17,9 @@ from pagerite.data import Data, Node, sorted_nodes
|
|||||||
|
|
||||||
|
|
||||||
class Hello(msgspec.Struct, tag="hello"):
|
class Hello(msgspec.Struct, tag="hello"):
|
||||||
"""Client greeting on connect: the target languages it handles."""
|
"""Client greeting on connect: the language codes its model CAN produce
|
||||||
|
(capabilities). The server offers jobs only in the intersection with
|
||||||
|
the wanted target languages (``Data.translate_langs``)."""
|
||||||
|
|
||||||
langs: list[str]
|
langs: list[str]
|
||||||
|
|
||||||
@@ -31,24 +34,33 @@ class TransItem(msgspec.Struct):
|
|||||||
|
|
||||||
|
|
||||||
class Job(msgspec.Struct, tag="job"):
|
class Job(msgspec.Struct, tag="job"):
|
||||||
"""Server push: pending items for one language."""
|
"""Server push: ONE fragment to translate.
|
||||||
|
|
||||||
|
Exactly one job is in flight per connection — the next is sent only
|
||||||
|
after this one's Result. Clients wanting parallelism open multiple
|
||||||
|
connections."""
|
||||||
|
|
||||||
lang: str
|
lang: str
|
||||||
items: list[TransItem]
|
key: bytes #: 9-byte chunk hash (base64 in the JSON frame)
|
||||||
|
text: str
|
||||||
|
path: str #: article it came from ("" = front page), no leading slash
|
||||||
|
kind: str #: "chunk" | "title"
|
||||||
|
|
||||||
|
|
||||||
class TransResult(msgspec.Struct):
|
class TransResult(msgspec.Struct):
|
||||||
"""One translated fragment."""
|
"""One translated fragment (storage level, see store_results)."""
|
||||||
|
|
||||||
key: bytes
|
key: bytes
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
|
|
||||||
class Result(msgspec.Struct, tag="result"):
|
class Result(msgspec.Struct, tag="result"):
|
||||||
"""Client reply: a batch of translations for one language."""
|
"""Client reply: the translation of the connection's current Job
|
||||||
|
(must match its lang and key exactly)."""
|
||||||
|
|
||||||
lang: str
|
lang: str
|
||||||
items: list[TransResult]
|
key: bytes
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
#: Union of the client -> server frames (the "type" tag selects).
|
#: Union of the client -> server frames (the "type" tag selects).
|
||||||
@@ -94,13 +106,13 @@ def pending_items(data: Data, lang: str) -> list[TransItem]:
|
|||||||
|
|
||||||
|
|
||||||
def store_results(data: Data, lang: str, items: list[TransResult]) -> list[str]:
|
def store_results(data: Data, lang: str, items: list[TransResult]) -> list[str]:
|
||||||
"""Store a batch of machine translations for ``lang``; return the paths
|
"""Store machine translations for ``lang``; return the paths of the
|
||||||
of the articles that gained at least one entry.
|
articles that gained at least one entry.
|
||||||
|
|
||||||
Pure data operations: the caller wraps this in a kanta transaction and
|
Pure data operations: the caller wraps this in a kanta transaction and
|
||||||
invalidates pages. Unknown keys are stored anyway (unreferenced hashes
|
invalidates pages. Unknown keys are stored anyway (unreferenced hashes
|
||||||
are never read, and the content may simply have moved on since the job
|
are never read, and the content may simply have moved on since the job
|
||||||
was pushed); duplicates within a batch overwrite, last wins. Every
|
was pushed); re-storing an existing key overwrites, last wins. Every
|
||||||
article that gained an entry gets ``node.langs[lang]`` set (the
|
article that gained an entry gets ``node.langs[lang]`` set (the
|
||||||
availability index, docs/migrate.md) — because chunks are
|
availability index, docs/migrate.md) — because chunks are
|
||||||
content-addressed, that includes pages merely sharing a fragment.
|
content-addressed, that includes pages merely sharing a fragment.
|
||||||
|
|||||||
Reference in New Issue
Block a user