You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
324 lines
12 KiB
324 lines
12 KiB
""" |
|
One-time migration: swap talks._key from anforande_id (UUID) to id (dok_id). |
|
|
|
After this migration: |
|
- talks._key = old "id" field (e.g. "H90982") |
|
- talks.anforande_id = preserved old UUID for reference |
|
- chunks.parent_id = "talks/H90982" (was "talks/<uuid>") |
|
- debates.talk_ids = ["talks/H90982", ...] |
|
|
|
Execution order matters: |
|
Step 1 — build mapping from talks (while old docs exist) |
|
Step 2 — update chunks.parent_id (single AQL; DOCUMENT lookup needs old talks) |
|
Step 3 — update debates.talk_ids (single AQL; DOCUMENT lookup needs old talks) |
|
Step 4 — re-key talks (insert new + delete old) |
|
|
|
Usage: |
|
python scripts/migrate_keys.py # live run |
|
python scripts/migrate_keys.py --dry-run # count only, no changes |
|
""" |
|
|
|
import os |
|
import sys |
|
import argparse |
|
import logging |
|
import time |
|
|
|
os.chdir("/home/lasse/riksdagen") |
|
sys.path.append("/home/lasse/riksdagen") |
|
|
|
# Connect directly to localhost with no read timeout. |
|
# The default 60s client timeout is too short for full-collection scans. |
|
from arango import ArangoClient |
|
from arango.http import DefaultHTTPClient |
|
from dotenv import load_dotenv |
|
load_dotenv() |
|
_client = ArangoClient(hosts="http://localhost:8529", http_client=DefaultHTTPClient(request_timeout=None)) |
|
db = _client.db("riksdagen", username="riksdagen", password=os.environ["ARANGO_PWD"]) |
|
|
|
logging.basicConfig( |
|
level=logging.INFO, |
|
format="%(asctime)s [%(levelname)s] %(message)s", |
|
) |
|
logger = logging.getLogger(__name__) |
|
|
|
BATCH_SIZE = 500 |
|
|
|
|
|
def safe_batch_execute(func, *args, max_retries=5, **kwargs): |
|
"""Executes an ArangoDB batch operation with exponential backoff for lock timeouts.""" |
|
for attempt in range(max_retries): |
|
try: |
|
return func(*args, **kwargs) |
|
except Exception as e: |
|
# Check if the error is a lock timeout (ERR 1200) |
|
if "1200" in str(e) or "timeout waiting to lock key" in str(e).lower(): |
|
if attempt < max_retries - 1: |
|
time.sleep(0.5 * (2 ** attempt)) # 0.5s, 1.0s, 2.0s, 4.0s |
|
continue |
|
# Raise immediately if it's a different error or we're out of retries |
|
raise e |
|
|
|
|
|
def build_mapping() -> dict[str, str]: |
|
"""Return {old_uuid: new_id} for every talk that still has a UUID _key.""" |
|
logger.info("Building old→new key mapping from talks collection...") |
|
cursor = db.aql.execute( |
|
""" |
|
FOR t IN talks |
|
FILTER t.id != null AND t.id != "" |
|
FILTER t._key != t.id |
|
RETURN {old: t._key, new: t.id} |
|
""", |
|
batch_size=5000, |
|
ttl=600, |
|
) |
|
mapping = {row["old"]: row["new"] for row in cursor} |
|
logger.info(f" {len(mapping)} talks need re-keying") |
|
return mapping |
|
|
|
|
|
def migrate_chunks(mapping: dict[str, str], dry_run: bool) -> None: |
|
""" |
|
Update chunks.parent_id from 'talks/{uuid}' → 'talks/{id}'. |
|
|
|
Read phase: stream chunk _key + parent_id in Python (read-only, no locks). |
|
Write phase: batch UPDATE by _key (point locks, no cross-collection join). |
|
Must run BEFORE talk re-keying so the mapping is still valid. |
|
""" |
|
if not db.has_collection("chunks"): |
|
logger.info("No chunks collection — skipping.") |
|
return |
|
|
|
logger.info("Step 2: Scanning chunks for stale parent_ids...") |
|
cursor = db.aql.execute( |
|
"FOR c IN chunks RETURN {k: c._key, p: c.parent_id}", |
|
batch_size=10000, |
|
ttl=3600, |
|
stream=True, |
|
) |
|
updates = [] |
|
for row in cursor: |
|
parent = row["p"] or "" |
|
if not parent.startswith("talks/"): |
|
continue |
|
old_talk_key = parent[len("talks/"):] |
|
new_talk_key = mapping.get(old_talk_key) |
|
if new_talk_key: |
|
updates.append({"k": row["k"], "p": f"talks/{new_talk_key}"}) |
|
|
|
# Deduplicate updates to prevent batch deadlocks on live data |
|
unique_updates = {u["k"]: u["p"] for u in updates} |
|
updates = [{"k": k, "p": p} for k, p in unique_updates.items()] |
|
|
|
logger.info(f" {len(updates)} unique chunks need updating") |
|
if dry_run: |
|
logger.info(" [dry-run] skipping writes") |
|
return |
|
|
|
chunks_col = db.collection("chunks") |
|
total = 0 |
|
for i in range(0, len(updates), 100): |
|
batch = [{"_key": u["k"], "parent_id": u["p"]} for u in updates[i : i + 100]] |
|
safe_batch_execute(chunks_col.update_many, batch, silent=True) |
|
total += len(batch) |
|
print(f" chunks updated: {total}/{len(updates)}", end="\r") |
|
print() |
|
logger.info(f"Step 2 done: {total} chunks updated") |
|
|
|
|
|
def migrate_debates(mapping: dict[str, str], dry_run: bool) -> None: |
|
""" |
|
Update debates.talk_ids from ["talks/{uuid}", ...] → ["talks/{id}", ...]. |
|
|
|
Read phase: stream debate _key + talk_ids in Python (read-only, no locks). |
|
Write phase: batch UPDATE by _key (point locks, no cross-collection join). |
|
Must run BEFORE talk re-keying so the mapping is still valid. |
|
""" |
|
if not db.has_collection("debates"): |
|
logger.info("No debates collection — skipping.") |
|
return |
|
|
|
logger.info("Step 3: Scanning debates for stale talk_ids...") |
|
cursor = db.aql.execute( |
|
"FOR d IN debates FILTER d.talk_ids != null RETURN {k: d._key, ids: d.talk_ids}", |
|
batch_size=5000, |
|
ttl=300, |
|
) |
|
updates = [] |
|
for row in cursor: |
|
new_ids = [] |
|
changed = False |
|
for tid in row["ids"]: |
|
if tid.startswith("talks/"): |
|
old_key = tid[len("talks/"):] |
|
new_key = mapping.get(old_key) |
|
if new_key: |
|
new_ids.append(f"talks/{new_key}") |
|
changed = True |
|
continue |
|
new_ids.append(tid) |
|
if changed: |
|
updates.append({"k": row["k"], "ids": new_ids}) |
|
|
|
# Deduplicate updates to prevent batch deadlocks on live data |
|
unique_updates = {u["k"]: u["ids"] for u in updates} |
|
updates = [{"k": k, "ids": ids} for k, ids in unique_updates.items()] |
|
|
|
logger.info(f" {len(updates)} unique debates need updating") |
|
if dry_run: |
|
logger.info(" [dry-run] skipping writes") |
|
return |
|
|
|
debates_col = db.collection("debates") |
|
total = 0 |
|
for i in range(0, len(updates), 100): |
|
batch = [{"_key": u["k"], "talk_ids": u["ids"]} for u in updates[i : i + 100]] |
|
safe_batch_execute(debates_col.update_many, batch, silent=True) |
|
total += len(batch) |
|
print(f" debates updated: {total}/{len(updates)}", end="\r") |
|
print() |
|
logger.info(f"Step 3 done: {total} debates updated") |
|
|
|
|
|
def migrate_talks(mapping: dict[str, str], dry_run: bool) -> None: |
|
"""Re-insert talks with new _key, preserve old UUID as anforande_id field.""" |
|
talks_col = db.collection("talks") |
|
old_keys = list(mapping.keys()) |
|
total = len(old_keys) |
|
done = 0 |
|
|
|
logger.info(f"Step 4: Re-keying {total} talks (batch_size={BATCH_SIZE})...") |
|
|
|
for i in range(0, total, BATCH_SIZE): |
|
batch_old_keys = old_keys[i : i + BATCH_SIZE] |
|
|
|
docs = list(db.aql.execute( |
|
"FOR k IN @keys RETURN DOCUMENT(CONCAT('talks/', k))", |
|
bind_vars={"keys": batch_old_keys}, |
|
batch_size=BATCH_SIZE, |
|
)) |
|
|
|
new_docs = [] |
|
for doc in docs: |
|
if doc is None: |
|
continue |
|
old_key = doc["_key"] |
|
new_key = mapping.get(old_key) |
|
if not new_key: |
|
continue |
|
new_doc = {k: v for k, v in doc.items() if k not in ("_key", "_id", "_rev")} |
|
new_doc["_key"] = new_key |
|
new_doc["anforande_id"] = old_key # preserve UUID |
|
new_docs.append(new_doc) |
|
|
|
done += len(new_docs) |
|
print(f" talks {done}/{total}", end="\r") |
|
|
|
if dry_run: |
|
continue |
|
|
|
if new_docs: |
|
safe_batch_execute(talks_col.insert_many, new_docs, overwrite=True) |
|
|
|
safe_batch_execute(talks_col.delete_many, [{"_key": k} for k in batch_old_keys], silent=True) |
|
|
|
print() |
|
if dry_run: |
|
logger.info(f" [dry-run] {done} talks would be re-keyed") |
|
else: |
|
logger.info(f"Step 4 done: {done} talks re-keyed") |
|
|
|
|
|
def rekey_chunks(dry_run: bool) -> None: |
|
""" |
|
Re-key chunks from '{uuid}:{idx}' to '{short_id}:{idx}'. |
|
|
|
Talks are already re-keyed; use talks.anforande_id (preserved old UUID) for the mapping. |
|
Runs server-side via batched AQL INSERT+REMOVE to avoid transferring embedding vectors. |
|
Requires a persistent index on talks.anforande_id for performance. |
|
""" |
|
if not db.has_collection("chunks"): |
|
logger.info("No chunks collection — skipping chunk re-key.") |
|
return |
|
|
|
# Ensure index exists so the FILTER tt.anforande_id == old_uuid lookup is fast. |
|
talks_col = db.collection("talks") |
|
existing_fields = {f for idx in talks_col.indexes() for f in idx.get("fields", [])} |
|
if "anforande_id" not in existing_fields: |
|
logger.info(" Creating persistent index on talks.anforande_id...") |
|
talks_col.add_persistent_index(fields=["anforande_id"], unique=False, sparse=True) |
|
|
|
if dry_run: |
|
result = list(db.aql.execute( |
|
'FOR c IN chunks FILTER CONTAINS(c._key, "-") COLLECT WITH COUNT INTO n RETURN n', |
|
ttl=120, |
|
)) |
|
logger.info(f" [dry-run] {result[0] if result else 0} chunks would be re-keyed") |
|
return |
|
|
|
logger.info("Step 5: Re-keying chunks from UUID to short-id format...") |
|
total = 0 |
|
while True: |
|
result = list(db.aql.execute( |
|
""" |
|
FOR c IN chunks |
|
FILTER CONTAINS(c._key, "-") |
|
LET parts = SPLIT(c._key, ":") |
|
LET old_uuid = parts[0] |
|
LET idx = parts[1] |
|
LET t = FIRST(FOR tt IN talks FILTER tt.anforande_id == old_uuid LIMIT 1 RETURN tt) |
|
FILTER t != null |
|
LET new_key = CONCAT(t._key, ":", idx) |
|
LET new_doc = MERGE(UNSET(c, "_key", "_id", "_rev"), |
|
{_key: new_key, parent_id: CONCAT("talks/", t._key)}) |
|
INSERT new_doc INTO chunks OPTIONS {overwriteMode: "ignore"} |
|
REMOVE c IN chunks |
|
LIMIT 200 |
|
RETURN 1 |
|
""", |
|
ttl=300, |
|
)) |
|
batch_count = len(result) |
|
total += batch_count |
|
print(f" chunks re-keyed: {total}", end="\r") |
|
if batch_count == 0: |
|
break |
|
print() |
|
logger.info(f"Step 5 done: {total} chunks re-keyed") |
|
|
|
|
|
def main(): |
|
parser = argparse.ArgumentParser(description="Migrate talks _key from UUID to id (dok_id)") |
|
parser.add_argument("--dry-run", action="store_true", help="Print counts only, make no changes") |
|
parser.add_argument("--rekey-chunks", action="store_true", help="Also re-key chunks to short-id format (step 5)") |
|
args = parser.parse_args() |
|
|
|
if args.dry_run: |
|
logger.info("=== DRY RUN — no data will be modified ===") |
|
else: |
|
logger.info("=== LIVE RUN — data will be modified ===") |
|
|
|
mapping = build_mapping() |
|
if not mapping: |
|
logger.info("Nothing to migrate — all talks already use short keys.") |
|
if args.rekey_chunks: |
|
rekey_chunks(args.dry_run) |
|
return |
|
|
|
# Steps 2 and 3 must run before step 4 (talk re-keying) — mapping uses old UUIDs as keys. |
|
migrate_chunks(mapping, args.dry_run) |
|
migrate_debates(mapping, args.dry_run) |
|
migrate_talks(mapping, args.dry_run) |
|
|
|
if args.rekey_chunks: |
|
rekey_chunks(args.dry_run) |
|
|
|
if args.dry_run: |
|
logger.info("=== Dry run complete. Re-run without --dry-run to apply. ===") |
|
else: |
|
logger.info("=== Migration complete. ===") |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |