Оба места утверждали, что у Домклика один выделенный резидентный прокси и пула нет. Это перестало быть правдой ещё в #2800 (миграция 253 сняла резервацию узла), но текст остался — и именно на него опирался тикет #3189, поставленный под «калибровку» ограничения, которого не существует. Свип к тому же ходит через пул давно (serp.py:359), а бэкфилл подключён к нему в PR #3222. Заодно докстринг называл не тот ограничитель: свип кладёт не счётчик провалов lease, а break по первому DomClickBlockedError (#2854) — до ротации дело не доходит ни при каком счётчике, отсюда buckets_completed=0. Только комментарии, поведение не меняется. Миграция 175 уже применена, а _schema_migrations трекает по имени файла без checksum — правка текста её не перезапустит.
136 lines
5.7 KiB
Python
136 lines
5.7 KiB
Python
"""Удаляет ghost docs из decisions/, sessions/, sources/ в CouchDB —
|
||
те что есть в CouchDB но отсутствуют в локальном vault.
|
||
"""
|
||
import os
|
||
from pathlib import Path
|
||
from urllib.parse import quote
|
||
|
||
import requests
|
||
|
||
VAULT = Path(r"C:\Users\user\Documents\gendesign-memory")
|
||
BASE = "https://obsidian.gendsgn.ru/gendesign-vault"
|
||
AUTH = ("obsidian", os.environ["COUCHDB_PASSWORD"])
|
||
TARGETS = ("decisions/", "sessions/", "sources/")
|
||
|
||
|
||
def main() -> None:
|
||
# 1. Local files (case-insensitive set, forward-slash paths)
|
||
local: set[str] = set()
|
||
for top in ("decisions", "sessions", "sources"):
|
||
d = VAULT / top
|
||
if d.exists():
|
||
for f in d.rglob("*.md"):
|
||
rel = str(f.relative_to(VAULT)).replace(os.sep, "/").lower()
|
||
local.add(rel)
|
||
print(f"Local files in target dirs: {len(local)}")
|
||
|
||
# 2. Fetch ALL CouchDB docs (paginated — _all_docs усекается limit'ом,
|
||
# а неполный alive-набор приведёт к удалению живых chunks как orphan).
|
||
# Идём постранично через startkey_docid: last id страницы становится
|
||
# startkey следующей, дублирующая первая строка отбрасывается. Цикл
|
||
# завершается, когда страница вернула меньше page_size строк.
|
||
page_size = 10000
|
||
rows: list[dict] = []
|
||
startkey_docid: str | None = None
|
||
while True:
|
||
params: dict[str, object] = {"limit": page_size}
|
||
if startkey_docid is not None:
|
||
# startkey должен быть JSON-строкой
|
||
params["startkey"] = f'"{startkey_docid}"'
|
||
params["startkey_docid"] = startkey_docid
|
||
r = requests.get(f"{BASE}/_all_docs", auth=AUTH,
|
||
params=params, timeout=60)
|
||
page = r.json()["rows"]
|
||
if startkey_docid is not None and page and page[0]["id"] == startkey_docid:
|
||
# первая строка дублирует startkey предыдущей страницы — отбрасываем
|
||
page = page[1:]
|
||
rows.extend(page)
|
||
# Полная страница (page_size до отбрасывания дубля) => возможно есть ещё.
|
||
# Если строк меньше — это последняя страница.
|
||
if len(page) + (1 if startkey_docid is not None else 0) < page_size:
|
||
break
|
||
startkey_docid = rows[-1]["id"]
|
||
print(f"CouchDB docs fetched (paginated): {len(rows)}")
|
||
|
||
# 3. Identify ghosts
|
||
ghosts: list[tuple[str, str]] = []
|
||
keepers: list[str] = []
|
||
for row in rows:
|
||
did = row["id"]
|
||
if not did.endswith(".md"):
|
||
continue
|
||
if not did.startswith(TARGETS):
|
||
continue
|
||
if did.lower() in local:
|
||
keepers.append(did)
|
||
else:
|
||
ghosts.append((did, row["value"]["rev"]))
|
||
|
||
print(f"Keepers (matched local): {len(keepers)}")
|
||
print(f"Ghosts (no local match): {len(ghosts)}")
|
||
for did, _ in ghosts[:30]:
|
||
print(f" ghost: {did}")
|
||
if len(ghosts) > 30:
|
||
print(f" ... +{len(ghosts) - 30} more")
|
||
|
||
if not ghosts:
|
||
print("Nothing to do.")
|
||
return
|
||
|
||
# 4. Сначала найдём chunks которые ссылаются ghosts. Это потенциальные orphans.
|
||
ghost_chunk_refs: set[str] = set()
|
||
for did, _ in ghosts:
|
||
rd = requests.get(f"{BASE}/{quote(did, safe='')}", auth=AUTH, timeout=30)
|
||
if rd.status_code == 200:
|
||
d = rd.json()
|
||
for cid in d.get("children", []):
|
||
ghost_chunk_refs.add(cid)
|
||
for cid in d.get("eden", {}):
|
||
ghost_chunk_refs.add(cid)
|
||
print(f"Chunks referenced by ghosts: {len(ghost_chunk_refs)}")
|
||
|
||
# 5. Проверяем у скольких из них ещё есть alive parent
|
||
ghost_ids = {did for did, _ in ghosts}
|
||
alive_md = [r["id"] for r in rows
|
||
if r["id"].endswith(".md") and r["id"] not in ghost_ids]
|
||
print(f"Scanning {len(alive_md)} alive .md docs for chunk refs...")
|
||
alive_chunks: set[str] = set()
|
||
for i, did in enumerate(alive_md):
|
||
if i % 100 == 0 and i > 0:
|
||
print(f" {i}/{len(alive_md)}")
|
||
rd = requests.get(f"{BASE}/{quote(did, safe='')}", auth=AUTH, timeout=30)
|
||
if rd.status_code == 200:
|
||
d = rd.json()
|
||
for cid in d.get("children", []):
|
||
alive_chunks.add(cid)
|
||
for cid in d.get("eden", {}):
|
||
alive_chunks.add(cid)
|
||
|
||
orphans = ghost_chunk_refs - alive_chunks
|
||
print(f"Orphan chunks (only ghosts reference them): {len(orphans)}")
|
||
|
||
# 6. Получаем _rev для orphan chunks
|
||
chunk_revs: dict[str, str] = {}
|
||
chunk_id_to_rev = {r["id"]: r["value"]["rev"] for r in rows if r["id"].startswith("h:")}
|
||
for cid in orphans:
|
||
if cid in chunk_id_to_rev:
|
||
chunk_revs[cid] = chunk_id_to_rev[cid]
|
||
|
||
# 7. Bulk delete
|
||
del_docs = [{"_id": did, "_rev": rev, "_deleted": True} for did, rev in ghosts]
|
||
del_docs.extend({"_id": cid, "_rev": rev, "_deleted": True}
|
||
for cid, rev in chunk_revs.items())
|
||
|
||
print(f"Bulk delete {len(del_docs)} docs ({len(ghosts)} ghost main + "
|
||
f"{len(chunk_revs)} orphan chunks)...")
|
||
rb = requests.post(f"{BASE}/_bulk_docs", json={"docs": del_docs},
|
||
auth=AUTH, timeout=300)
|
||
if rb.status_code in (200, 201):
|
||
ok = sum(1 for x in rb.json() if x.get("ok"))
|
||
print(f"Deleted: {ok}/{len(del_docs)}")
|
||
else:
|
||
print(f"Bulk delete failed: HTTP {rb.status_code} {rb.text[:200]}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|