search for standard sites pub-search.waow.tech
search zig blog atproto
0

Configure Feed

Select the types of activity you want to include in your feed.

pub-search / scripts / backfill-content-type
10 kB 298 lines
1#!/usr/bin/env -S uv run --script --quiet 2# /// script 3# requires-python = ">=3.12" 4# dependencies = ["httpx", "pydantic-settings"] 5# /// 6""" 7Backfill `documents.content_type` from PDS records. 8 9Reads `content.$type` from each record on its PDS and writes it back to Turso. 10Skips whitewind (no content.$type) and rows already populated. 11 12Does NOT bump `indexed_at` — content_type isn't in the local replica schema yet, 13so we don't want to force a full re-sync of every touched row. 14 15Usage: 16 TURSO_URL=libsql://... TURSO_TOKEN=... ./scripts/backfill-content-type 17 ./scripts/backfill-content-type --dry-run 18 ./scripts/backfill-content-type --limit 100 # cap distinct (did, collection) groups 19 ./scripts/backfill-content-type --workers 10 # PDS concurrency 20""" 21 22from __future__ import annotations 23 24import argparse 25import os 26import sys 27import time 28from concurrent.futures import ThreadPoolExecutor, as_completed 29from dataclasses import dataclass 30 31import httpx 32from pydantic_settings import BaseSettings, SettingsConfigDict 33 34 35class Settings(BaseSettings): 36 model_config = SettingsConfigDict( 37 env_file=os.environ.get("ENV_FILE", ".env"), extra="ignore" 38 ) 39 40 turso_url: str 41 turso_token: str 42 43 @property 44 def turso_host(self) -> str: 45 url = self.turso_url 46 if url.startswith("libsql://"): 47 url = url[len("libsql://") :] 48 return url 49 50 51# ---- Turso HTTP pipeline ----------------------------------------------------- 52 53 54def turso_pipeline(settings: Settings, requests: list[dict]) -> list[dict]: 55 """POST a batch of statements; return the per-statement results.""" 56 last_err: Exception | None = None 57 for attempt in range(3): 58 try: 59 resp = httpx.post( 60 f"https://{settings.turso_host}/v2/pipeline", 61 headers={ 62 "Authorization": f"Bearer {settings.turso_token}", 63 "Content-Type": "application/json", 64 }, 65 json={"requests": [*requests, {"type": "close"}]}, 66 timeout=180, 67 ) 68 resp.raise_for_status() 69 return resp.json()["results"] 70 except (httpx.ReadTimeout, httpx.ConnectError, httpx.RemoteProtocolError) as e: 71 last_err = e 72 print(f" turso pipeline attempt {attempt + 1} failed: {type(e).__name__}; retrying...", file=sys.stderr) 73 time.sleep(2 * (attempt + 1)) 74 assert last_err is not None 75 raise last_err 76 77 78def turso_query(settings: Settings, sql: str, args: list | None = None) -> list[list]: 79 stmt: dict = {"sql": sql} 80 if args: 81 stmt["args"] = [ 82 {"type": "null"} if a is None else {"type": "text", "value": str(a)} 83 for a in args 84 ] 85 results = turso_pipeline(settings, [{"type": "execute", "stmt": stmt}]) 86 r = results[0] 87 if r["type"] == "error": 88 raise RuntimeError(f"Turso query error: {r['error']}") 89 rows = r["response"]["result"]["rows"] 90 return [[c["value"] if isinstance(c, dict) and "value" in c else None for c in row] for row in rows] 91 92 93def turso_batch_update(settings: Settings, updates: list[tuple[str, str, str]]) -> int: 94 """Apply UPDATE documents SET content_type=? WHERE did=? AND rkey=? in batches.""" 95 BATCH = 50 96 written = 0 97 for i in range(0, len(updates), BATCH): 98 chunk = updates[i : i + BATCH] 99 reqs = [ 100 { 101 "type": "execute", 102 "stmt": { 103 "sql": "UPDATE documents SET content_type = ? WHERE did = ? AND rkey = ?", 104 "args": [ 105 {"type": "text", "value": ct}, 106 {"type": "text", "value": did}, 107 {"type": "text", "value": rkey}, 108 ], 109 }, 110 } 111 for (ct, did, rkey) in chunk 112 ] 113 results = turso_pipeline(settings, reqs) 114 for r in results: 115 if r["type"] == "error": 116 print(f" turso UPDATE error: {r['error']}", file=sys.stderr) 117 continue 118 response = r.get("response") or {} 119 if response.get("type") != "execute": 120 continue # skip the trailing close response 121 written += int(response.get("result", {}).get("affected_row_count", 0)) 122 return written 123 124 125# ---- PDS resolution + fetch -------------------------------------------------- 126 127 128_pds_cache: dict[str, str | None] = {} 129 130 131def resolve_pds(did: str) -> str | None: 132 """Return PDS endpoint URL for a DID, or None if unresolvable.""" 133 if did in _pds_cache: 134 return _pds_cache[did] 135 url: str | None = None 136 try: 137 if did.startswith("did:plc:"): 138 resp = httpx.get(f"https://plc.directory/{did}", timeout=15) 139 resp.raise_for_status() 140 for svc in resp.json().get("service", []): 141 if svc.get("type") == "AtprotoPersonalDataServer": 142 url = svc["serviceEndpoint"] 143 break 144 elif did.startswith("did:web:"): 145 host = did[len("did:web:") :].replace(":", "/") 146 resp = httpx.get(f"https://{host}/.well-known/did.json", timeout=15, follow_redirects=True) 147 resp.raise_for_status() 148 for svc in resp.json().get("service", []): 149 if svc.get("type") == "AtprotoPersonalDataServer": 150 url = svc["serviceEndpoint"] 151 break 152 except Exception: 153 url = None 154 _pds_cache[did] = url 155 return url 156 157 158def list_records(pds: str, did: str, collection: str) -> list[dict]: 159 records: list[dict] = [] 160 cursor: str | None = None 161 while True: 162 params: dict = {"repo": did, "collection": collection, "limit": 100} 163 if cursor: 164 params["cursor"] = cursor 165 resp = httpx.get( 166 f"{pds}/xrpc/com.atproto.repo.listRecords", params=params, timeout=30 167 ) 168 resp.raise_for_status() 169 data = resp.json() 170 records.extend(data.get("records", [])) 171 cursor = data.get("cursor") 172 if not cursor: 173 break 174 return records 175 176 177# ---- Backfill -------------------------------------------------------------- 178 179 180@dataclass 181class Group: 182 did: str 183 collection: str 184 expected: int # how many rows we expect to update in this group 185 186 187def gather_groups(settings: Settings, limit: int | None) -> list[Group]: 188 sql = ( 189 "SELECT did, source_collection, COUNT(*) " 190 "FROM documents " 191 "WHERE content_type IS NULL " 192 " AND source_collection NOT LIKE 'com.whtwnd.%' " 193 "GROUP BY did, source_collection " 194 "ORDER BY COUNT(*) DESC" 195 ) 196 if limit: 197 sql += f" LIMIT {int(limit)}" 198 rows = turso_query(settings, sql) 199 return [Group(did=r[0], collection=r[1], expected=int(r[2])) for r in rows] 200 201 202def fetch_group(group: Group) -> tuple[Group, list[tuple[str, str, str]] | None, str]: 203 """Return (group, [(content_type, did, rkey), ...] | None, status).""" 204 pds = resolve_pds(group.did) 205 if not pds: 206 return group, None, "no-pds" 207 try: 208 records = list_records(pds, group.did, group.collection) 209 except httpx.HTTPStatusError as e: 210 if e.response.status_code == 400: 211 return group, [], "empty" 212 return group, None, f"pds-{e.response.status_code}" 213 except Exception as e: 214 return group, None, f"pds-err:{type(e).__name__}" 215 216 updates: list[tuple[str, str, str]] = [] 217 for rec in records: 218 uri = rec.get("uri", "") 219 rkey = uri.rsplit("/", 1)[-1] if uri else "" 220 if not rkey: 221 continue 222 value = rec.get("value") or {} 223 content = value.get("content") 224 content_type = "" 225 if isinstance(content, dict): 226 ct = content.get("$type") 227 if isinstance(ct, str): 228 content_type = ct 229 updates.append((content_type, group.did, rkey)) 230 return group, updates, "ok" 231 232 233def main() -> int: 234 parser = argparse.ArgumentParser(description="Backfill documents.content_type from PDS records") 235 parser.add_argument("--dry-run", action="store_true", help="Fetch from PDSes but don't write") 236 parser.add_argument("--limit", type=int, default=None, help="Cap distinct (did, collection) groups (debug)") 237 parser.add_argument("--workers", type=int, default=8, help="PDS concurrency") 238 args = parser.parse_args() 239 240 try: 241 settings = Settings() # type: ignore 242 except Exception as e: 243 print(f"error loading settings: {e}", file=sys.stderr) 244 print("required env vars: TURSO_URL, TURSO_TOKEN", file=sys.stderr) 245 return 1 246 247 print("loading groups...") 248 groups = gather_groups(settings, args.limit) 249 total_expected = sum(g.expected for g in groups) 250 print(f" {len(groups)} (did, collection) groups, {total_expected} rows to attempt") 251 252 if not groups: 253 print("nothing to backfill") 254 return 0 255 256 pending_writes: list[tuple[str, str, str]] = [] 257 started = time.time() 258 done = 0 259 status_counts: dict[str, int] = {} 260 261 with ThreadPoolExecutor(max_workers=args.workers) as pool: 262 futures = {pool.submit(fetch_group, g): g for g in groups} 263 for fut in as_completed(futures): 264 group, updates, status = fut.result() 265 status_counts[status] = status_counts.get(status, 0) + 1 266 done += 1 267 if updates: 268 pending_writes.extend(updates) 269 270 if done % 50 == 0 or done == len(groups): 271 elapsed = time.time() - started 272 rate = done / elapsed if elapsed > 0 else 0 273 print( 274 f" [{done}/{len(groups)}] {rate:.1f} groups/s, " 275 f"{len(pending_writes)} pending writes, statuses={status_counts}" 276 ) 277 278 print(f"\nfetched {len(pending_writes)} record-level updates") 279 if args.dry_run: 280 print("dry-run: skipping writes") 281 # show a sample 282 by_ct: dict[str, int] = {} 283 for ct, _, _ in pending_writes: 284 by_ct[ct] = by_ct.get(ct, 0) + 1 285 print("content_type distribution (dry-run):") 286 for ct, n in sorted(by_ct.items(), key=lambda kv: -kv[1])[:20]: 287 print(f" {n:>6} {ct!r}") 288 return 0 289 290 print("writing...") 291 written = turso_batch_update(settings, pending_writes) 292 print(f" wrote {written} rows") 293 294 return 0 295 296 297if __name__ == "__main__": 298 sys.exit(main())