fix(agent): refresh — все ключи 400 это ошибка, число вызовов бисекции, missing без дублей
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
parent
1133cee873
commit
8ebf5250b7
22
src/agent.py
22
src/agent.py
@ -629,9 +629,12 @@ class Executor:
|
|||||||
if not got or start >= int(page.get("total") or 0):
|
if not got or start >= int(page.get("total") or 0):
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
def search_tolerant(self, keys: List[str], missing: List[str]) -> List[dict]:
|
def search_tolerant(self, keys: List[str], missing: List[str], errors: List[HttpError]) -> List[dict]:
|
||||||
"""key in (...): Jira Server отвечает 400 на несуществующий/недоступный ключ. Чанк делим пополам до
|
"""key in (...): Jira Server отвечает 400 на несуществующий/недоступный ключ. Чанк делим пополам до
|
||||||
одиночного ключа; одиночный с 400 — «не найден» (в missing), не ошибка. Прочие HttpError пробрасываются."""
|
одиночного ключа; одиночный с 400 — «не найден» (в missing), не ошибка. Прочие HttpError пробрасываются.
|
||||||
|
Первый 400 копится в errors: вызывающий пробрасывает его, если не найден НИ ОДИН ключ (неверная конфигурация,
|
||||||
|
а не удалённые задачи).
|
||||||
|
Стоимость: до ~2·m·log2(n) вызовов на m отсутствующих ключей из n — принятое ограничение, рулинг R-7."""
|
||||||
if not keys:
|
if not keys:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
@ -639,11 +642,13 @@ class Executor:
|
|||||||
except HttpError as e:
|
except HttpError as e:
|
||||||
if e.code != 400:
|
if e.code != 400:
|
||||||
raise
|
raise
|
||||||
|
if not errors:
|
||||||
|
errors.append(e)
|
||||||
if len(keys) == 1:
|
if len(keys) == 1:
|
||||||
missing.append(keys[0])
|
missing.append(keys[0])
|
||||||
return []
|
return []
|
||||||
mid = len(keys) // 2
|
mid = len(keys) // 2
|
||||||
return self.search_tolerant(keys[:mid], missing) + self.search_tolerant(keys[mid:], missing)
|
return self.search_tolerant(keys[:mid], missing, errors) + self.search_tolerant(keys[mid:], missing, errors)
|
||||||
|
|
||||||
def to_state(self, raw: dict) -> dict:
|
def to_state(self, raw: dict) -> dict:
|
||||||
f = raw.get("fields") or {}
|
f = raw.get("fields") or {}
|
||||||
@ -694,15 +699,18 @@ class Executor:
|
|||||||
|
|
||||||
def do_refresh(self, p: dict, cmd: dict) -> dict:
|
def do_refresh(self, p: dict, cmd: dict) -> dict:
|
||||||
missing: List[str] = []
|
missing: List[str] = []
|
||||||
|
errors: List[HttpError] = []
|
||||||
if p.get("jql"):
|
if p.get("jql"):
|
||||||
raws = self.search_all(p["jql"])
|
raws = self.search_all(p["jql"])
|
||||||
else:
|
else:
|
||||||
keys = [k for k in (p.get("keys") or []) if k]
|
keys = list(dict.fromkeys(k for k in (p.get("keys") or []) if k))
|
||||||
if not keys:
|
if not keys:
|
||||||
return self._out("done", {"issues": 0, "neighbors": 0, "pages": 0})
|
return self._out("done", {"issues": 0, "neighbors": 0, "pages": 0})
|
||||||
raws = []
|
raws = []
|
||||||
for i in range(0, len(keys), 100):
|
for i in range(0, len(keys), 100):
|
||||||
raws += self.search_tolerant(keys[i:i + 100], missing)
|
raws += self.search_tolerant(keys[i:i + 100], missing, errors)
|
||||||
|
if not raws and errors: # ни один ключ не найден: это конфигурация/запрос, а не удалённые задачи
|
||||||
|
raise errors[0]
|
||||||
fetched = {r["key"] for r in raws}
|
fetched = {r["key"] for r in raws}
|
||||||
known = set(self.hub.known_keys()) if self.hub else set()
|
known = set(self.hub.known_keys()) if self.hub else set()
|
||||||
linked = set()
|
linked = set()
|
||||||
@ -713,7 +721,7 @@ class Executor:
|
|||||||
absent = sorted(k for k in linked if k not in fetched and k not in known)
|
absent = sorted(k for k in linked if k not in fetched and k not in known)
|
||||||
neighbors = []
|
neighbors = []
|
||||||
for i in range(0, len(absent), 100):
|
for i in range(0, len(absent), 100):
|
||||||
neighbors += self.search_tolerant(absent[i:i + 100], missing)
|
neighbors += self.search_tolerant(absent[i:i + 100], missing, errors)
|
||||||
everything = raws + neighbors
|
everything = raws + neighbors
|
||||||
names = self.sprint_names([r["key"] for r in everything])
|
names = self.sprint_names([r["key"] for r in everything])
|
||||||
issues, links, seen = [], [], set()
|
issues, links, seen = [], [], set()
|
||||||
@ -738,7 +746,7 @@ class Executor:
|
|||||||
"page": pages, "last_page": i + 100 >= len(issues)})
|
"page": pages, "last_page": i + 100 >= len(issues)})
|
||||||
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}
|
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}
|
||||||
if missing:
|
if missing:
|
||||||
result["missing"] = sorted(missing)[:50]
|
result["missing"] = list(dict.fromkeys(missing))[:50]
|
||||||
return self._out("done", result)
|
return self._out("done", result)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -93,9 +93,30 @@ class RefreshTest(unittest.TestCase):
|
|||||||
out = self.ex.run({"id": 9, "type": "refresh",
|
out = self.ex.run({"id": 9, "type": "refresh",
|
||||||
"params": {"keys": ["AURORA-1", "AURORA-2", "AURORA-99", "AURORA-3", "AURORA-98"]}})
|
"params": {"keys": ["AURORA-1", "AURORA-2", "AURORA-99", "AURORA-3", "AURORA-98"]}})
|
||||||
self.assertEqual(out["status"], "done")
|
self.assertEqual(out["status"], "done")
|
||||||
self.assertEqual(out["result"], {"issues": 3, "neighbors": 0, "pages": 1, "missing": ["AURORA-98", "AURORA-99"]})
|
self.assertEqual(out["result"], {"issues": 3, "neighbors": 0, "pages": 1, "missing": ["AURORA-99", "AURORA-98"]})
|
||||||
self.assertEqual(sorted(i["key"] for i in self.hub.states[0]["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3"])
|
self.assertEqual(sorted(i["key"] for i in self.hub.states[0]["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3"])
|
||||||
|
|
||||||
|
def test_all_keys_400_fails(self):
|
||||||
|
out = self.ex.run({"id": 14, "type": "refresh", "params": {"keys": ["X-1", "X-2", "X-3"]}})
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
self.assertIn("HTTP 400", out["error"])
|
||||||
|
self.assertEqual(self.hub.states, [])
|
||||||
|
|
||||||
|
def test_bisect_cost_one_missing_of_100(self):
|
||||||
|
for i in range(1, 100):
|
||||||
|
self.fake.add("AURORA-%d" % i)
|
||||||
|
keys = ["AURORA-%d" % i for i in range(1, 101)] # AURORA-100 отсутствует
|
||||||
|
out = self.ex.run({"id": 15, "type": "refresh", "params": {"keys": keys}})
|
||||||
|
self.assertEqual(out["result"]["missing"], ["AURORA-100"])
|
||||||
|
searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"]
|
||||||
|
self.assertLessEqual(len(searches), 2 * 7 + 1)
|
||||||
|
|
||||||
|
def test_missing_deduped_and_truncated(self):
|
||||||
|
self.fake.add("AURORA-1")
|
||||||
|
keys = ["AURORA-1"] + ["GONE-%d" % i for i in range(1, 61)] + ["GONE-1"]
|
||||||
|
out = self.ex.run({"id": 16, "type": "refresh", "params": {"keys": keys}})
|
||||||
|
self.assertEqual(out["result"]["missing"], ["GONE-%d" % i for i in range(1, 51)])
|
||||||
|
|
||||||
def test_deleted_neighbor(self):
|
def test_deleted_neighbor(self):
|
||||||
self.fake.add("AURORA-1")
|
self.fake.add("AURORA-1")
|
||||||
self.fake.add("AURORA-2", in_base=False)
|
self.fake.add("AURORA-2", in_base=False)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user