diff --git a/src/agent.py b/src/agent.py index 8008f48..2b115c1 100644 --- a/src/agent.py +++ b/src/agent.py @@ -327,15 +327,27 @@ class EvaClient(Http): return self.call("CmfDocument.get", filter=[["code", "==", code]], fields=fields) def list_docs(self, flt: list, fields: List[str]) -> List[dict]: + """Весь список страницами по PAGE_SIZE. Если Eva не соблюдает slice (ответ без новых записей), обход + останавливается на собранном, а не крутится бесконечно (M-4).""" out, offset = [], 0 # type: List[dict], int + seen = set() # type: set while True: chunk = self.call("CmfDocument.list", filter=flt, fields=fields, slice=[offset, offset + self.PAGE_SIZE]) or [] if isinstance(chunk, dict): # обёрнутый результат chunk = chunk.get("items") or chunk.get("result") or [] - out.extend(chunk) + fresh = 0 + for d in chunk: + key = json.dumps(d, sort_keys=True, ensure_ascii=False) + if key not in seen: + seen.add(key) + out.append(d) + fresh += 1 if len(chunk) < self.PAGE_SIZE: return out + if not fresh: + log(" Eva: CmfDocument.list повторяет записи (slice не соблюдается?) — останов на %d" % len(out)) + return out offset += self.PAGE_SIZE def download(self, url: str, limit: int) -> bytes: @@ -808,9 +820,8 @@ class HubError(Exception): class Executor: - def __init__(self, eva: EvaClient, hub: Optional[HubClient], settings: dict): - self.eva, self.hub, self.s = eva, hub, settings - self.sleep = time.sleep + def __init__(self, eva: EvaClient, hub: Optional[HubClient]): + self.eva, self.hub = eva, hub def run(self, cmd: dict) -> dict: self.eva.reset_counters() @@ -824,6 +835,8 @@ class Executor: out["retryable"] = e.retryable out["peer"] = "хабом" return out + except EvaError as e: # Eva ответила `error` (нет прав, неверный фильтр): повтор не поможет (M-1) + return self._out("failed", error="Eva: %s" % e) # текст уже очищен от токена (решение 23) except HttpError as e: out = self._out("failed", error="HTTP %d %s" % (e.code, e.text[:1500])) out["retryable"] = e.code >= 500 # исход неизвестен: Runner повторит @@ -844,6 +857,19 @@ class Executor: except (ConnectionError, HttpError) as e: raise HubError(e) from None + def _send_pages(self, command_id, batch: list) -> None: + """Пачка страниц в стадию хаба. 413 (тело больше предела хаба) — ошибка с кодом страницы или границами пачки (M-3).""" + try: + self.hub.wiki_pages(command_id, batch) + except HttpError as e: + if e.code == 413: + what = ("страница %s" % batch[0]["code"] if len(batch) == 1 else + "пачка страниц %s…%s (%d шт.)" % (batch[0]["code"], batch[-1]["code"], len(batch))) + e = HttpError(413, "%s больше 16 МБ — хаб не принял: %s" % (what, e.text[:300]), e.headers) + raise HubError(e) from None + except ConnectionError as e: + raise HubError(e) from None + def do_wiki_sync(self, p: dict, cmd: dict) -> dict: """Дерево страниц Eva от корня → хаб пачками; отчёт — коды в порядке обхода (корень первым). Eva только читается, поэтому после перезапуска команда выполняется заново целиком (design 9).""" @@ -874,13 +900,13 @@ class Executor: "html": html, "attachments": attachments, "links": links} psize = len(json.dumps(page, ensure_ascii=False).encode("utf-8")) + 2 if batch and (len(batch) >= MAX_PAGES or size + psize > MAX_BATCH_BYTES): - self._hub(self.hub.wiki_pages, cmd["id"], batch) + self._send_pages(cmd["id"], batch) batch, size = [], 0 batch.append(page) size += psize codes.append(code) if batch: - self._hub(self.hub.wiki_pages, cmd["id"], batch) + self._send_pages(cmd["id"], batch) title = (root.get("name") or "").strip() or root_code return self._out("done", {"root_title": title, "codes": codes, "eva_calls": self.eva.calls}) @@ -1086,8 +1112,7 @@ class Runner: def load_settings(self) -> None: self.settings = self.hub.settings() - self.executor = Executor(self.eva, self.hub, self.settings) - self.executor.sleep = self.sleep + self.executor = Executor(self.eva, self.hub) self.eva.pacing = float(self.settings.get("wiki_pacing", 0.2)) log("настройки хаба v%s" % self.settings.get("settings_version")) diff --git a/tests/test_eva_client.py b/tests/test_eva_client.py index 6e141e1..0349714 100644 --- a/tests/test_eva_client.py +++ b/tests/test_eva_client.py @@ -74,6 +74,26 @@ class EvaClientTest(unittest.TestCase): self.assertEqual([r["body"]["kwargs"]["slice"] for r in self.eva.api_calls], [[0, 200], [200, 400], [400, 600]]) + def test_list_slice_ignored_returns_all(self): + """M-4: Eva не поддерживает slice и отдаёт весь список каждый раз — второй ответ без новых записей, стоп.""" + orig = self.eva.rpc + self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=None)) + for i in range(450): + self.eva.add_doc(i + 1, "DOC-%d" % (i + 1), project="P") + docs = self.cli.list_docs([["project.code", "==", "P"]], ["id", "code"]) + self.assertEqual(len(docs), 450) + self.assertEqual(len(self.eva.api_calls), 2) + + def test_list_slice_ignored_same_page(self): + """M-4: Eva всегда отдаёт первые 200 — без защиты цикл бесконечен.""" + orig = self.eva.rpc + self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=[0, 200])) + for i in range(450): + self.eva.add_doc(i + 1, "DOC-%d" % (i + 1), project="P") + docs = self.cli.list_docs([["project.code", "==", "P"]], ["id", "code"]) + self.assertEqual([d["code"] for d in docs], ["DOC-%d" % (i + 1) for i in range(200)]) + self.assertEqual(len(self.eva.api_calls), 2) + def test_list_unwraps_dict(self): orig = self.eva.rpc self.eva.rpc = lambda m, kw: {"items": orig(m, kw)} diff --git a/tests/test_wiki_fetch.py b/tests/test_wiki_fetch.py index ac4918f..4d99ec9 100644 --- a/tests/test_wiki_fetch.py +++ b/tests/test_wiki_fetch.py @@ -19,7 +19,7 @@ class WikiFetchTest(unittest.TestCase): cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token) client = agent.EvaClient(cfg) client.sleep = no_sleep - self.ex = agent.Executor(client, agent.HubClient(cfg), dict(SETTINGS)) + self.ex = agent.Executor(client, agent.HubClient(cfg)) def fetch(self, url): return self.ex.run({"id": 1, "type": "wiki_fetch", "params": {"attachment_id": 7, "url": url}}) diff --git a/tests/test_wiki_sync.py b/tests/test_wiki_sync.py index 5ac9beb..c051025 100644 --- a/tests/test_wiki_sync.py +++ b/tests/test_wiki_sync.py @@ -17,7 +17,7 @@ class SyncBase(unittest.TestCase): self.cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token) self.client = agent.EvaClient(self.cfg) self.client.sleep = no_sleep - self.ex = agent.Executor(self.client, agent.HubClient(self.cfg), dict(SETTINGS)) + self.ex = agent.Executor(self.client, agent.HubClient(self.cfg)) def sync(self, root="DOC-1", label="tz"): cid = self.hub.enqueue("wiki_sync", {"label": label, "root": root}) @@ -168,6 +168,42 @@ class WikiSyncTest(SyncBase): self.assertIn("422", out["error"]) self.assertIn("DOC-1: html", out["error"]) + def test_eva_rpc_error_reported(self): + """M-1: `error` JSON-RPC Eva — failed «Eva: <текст>», без повтора и без «внутренней ошибки агента».""" + self.eva.add_doc(1, "DOC-1", "Корень", project="P") + self.eva.rpc_error_next("нет прав, токен eva-secret") + out = self.sync() + self.assertEqual(out["status"], "failed") + self.assertEqual(out["error"], "Eva: CmfDocument.get: нет прав, токен ***") + self.assertFalse(out.get("retryable")) + self.assertEqual(self.hub.pages, []) + + def test_eva_rpc_error_in_runner(self): + self.eva.add_doc(1, "DOC-1", "Корень", project="P") + self.eva.rpc_error_next("нет прав") + runner = agent.Runner(self.cfg, self.ex.hub, self.client, agent.Journal(os.devnull)) + runner.executor = self.ex + self.assertEqual(runner.run_once({"id": 1, "type": "wiki_sync", "params": {"root": "DOC-1"}})["error"], + "Eva: CmfDocument.get: нет прав") + + def test_hub_413_single_page(self): + """M-3: хаб не принял тело (413) — в ошибке названа страница.""" + self.eva.add_doc(1, "DOC-1", "Корень", project="P") + self.hub.reject_pages = (413, {"error": "too_large", "detail": "тело больше 16777216 байт"}) + out = self.sync() + self.assertEqual(out["status"], "failed") + self.assertIn("страница DOC-1 больше 16 МБ", out["error"]) + self.assertFalse(out.get("retryable")) + + def test_hub_413_batch(self): + self.eva.add_doc(1, "DOC-1", "Корень", project="P") + for i in (2, 3): + self.eva.add_doc(i, "DOC-%d" % i, "С", parent="DOC-1", project="P", orderno=i) + self.hub.reject_pages = (413, {"error": "too_large", "detail": "тело больше 16777216 байт"}) + out = self.sync() + self.assertEqual(out["status"], "failed") + self.assertIn("пачка страниц DOC-1…DOC-3 (3 шт.) больше 16 МБ", out["error"]) + def test_empty_name_falls_back_to_code(self): self.eva.add_doc(1, "DOC-1", "", project="P") # хаб отклоняет пустой title (stage._check) out = self.sync()