Upload app.py with huggingface_hub
Browse files
app.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
import os
|
| 2 |
import time
|
| 3 |
import logging
|
|
|
|
| 4 |
import bisect
|
|
|
|
| 5 |
from fastapi import FastAPI, HTTPException, Query
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
from huggingface_hub import snapshot_download
|
|
@@ -17,8 +19,12 @@ CACHE_DIR = "/data/tgdb_cache"
|
|
| 17 |
|
| 18 |
dataset = None
|
| 19 |
user_id_index = None
|
|
|
|
|
|
|
| 20 |
stats = {"startup_time": 0, "total_files": 0, "total_row_groups": 0, "total_rows": 0, "queries": 0}
|
| 21 |
|
|
|
|
|
|
|
| 22 |
def find_parquet(base):
|
| 23 |
files = []
|
| 24 |
for root, _, names in os.walk(base):
|
|
@@ -27,7 +33,6 @@ def find_parquet(base):
|
|
| 27 |
files.append(os.path.join(root, n))
|
| 28 |
return sorted(files)
|
| 29 |
|
| 30 |
-
|
| 31 |
class UserIdIndex:
|
| 32 |
def __init__(self):
|
| 33 |
self.entries = []
|
|
@@ -39,7 +44,7 @@ class UserIdIndex:
|
|
| 39 |
schema = arrow_dataset.schema
|
| 40 |
uid_idx = schema.get_field_index("user_id")
|
| 41 |
if uid_idx < 0:
|
| 42 |
-
logger.error("user_id column not found
|
| 43 |
return
|
| 44 |
for frag in arrow_dataset.get_fragments():
|
| 45 |
meta = frag.metadata
|
|
@@ -56,49 +61,71 @@ class UserIdIndex:
|
|
| 56 |
|
| 57 |
def find(self, user_id):
|
| 58 |
idx = bisect.bisect_right(self.keys, user_id) - 1
|
| 59 |
-
if idx >= 0:
|
| 60 |
mn, mx, path, rg = self.entries[idx]
|
| 61 |
if mn <= user_id <= mx:
|
| 62 |
return path, rg
|
| 63 |
return None
|
| 64 |
|
| 65 |
-
|
| 66 |
def init_dataset():
|
| 67 |
-
global dataset, user_id_index
|
| 68 |
-
|
| 69 |
-
os.makedirs(CACHE_DIR, exist_ok=True)
|
| 70 |
-
files = find_parquet(CACHE_DIR)
|
| 71 |
-
|
| 72 |
-
if not files:
|
| 73 |
-
logger.info("Downloading dataset...")
|
| 74 |
-
snapshot_download(
|
| 75 |
-
repo_id=REPO_ID,
|
| 76 |
-
repo_type="dataset",
|
| 77 |
-
local_dir=CACHE_DIR,
|
| 78 |
-
local_dir_use_symlinks=False,
|
| 79 |
-
)
|
| 80 |
-
files = find_parquet(CACHE_DIR)
|
| 81 |
-
logger.info(f"Downloaded {len(files)} parquet files")
|
| 82 |
-
|
| 83 |
-
logger.info(f"Creating dataset from {len(files)} files...")
|
| 84 |
-
dataset = ds.dataset(files, format="parquet")
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
stats["total_files"] = len(files)
|
| 90 |
-
stats["total_row_groups"] = rg_count
|
| 91 |
-
stats["total_rows"] = row_count
|
| 92 |
-
|
| 93 |
-
logger.info(f"Dataset: {len(files)} files, {rg_count} row groups, {row_count:,} rows")
|
| 94 |
-
|
| 95 |
-
user_id_index = UserIdIndex()
|
| 96 |
-
user_id_index.build(dataset)
|
| 97 |
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
def query_user(user_id: int):
|
| 104 |
stats["queries"] += 1
|
|
@@ -112,17 +139,13 @@ def query_user(user_id: int):
|
|
| 112 |
tbl = pf.read_row_group(rg_idx, columns=ALL_COLS)
|
| 113 |
tbl = tbl.filter(pc.equal(pc.field("user_id"), user_id))
|
| 114 |
if len(tbl):
|
| 115 |
-
|
| 116 |
-
return tbl.to_pylist()[0], elapsed
|
| 117 |
except Exception as e:
|
| 118 |
logger.warning(f"Index lookup failed for {user_id}: {e}")
|
| 119 |
|
| 120 |
tbl = dataset.to_table(filter=pc.equal(pc.field("user_id"), user_id), columns=ALL_COLS)
|
| 121 |
elapsed = time.time() - t0
|
| 122 |
-
if len(tbl)
|
| 123 |
-
return None, elapsed
|
| 124 |
-
return tbl.to_pylist()[0], elapsed
|
| 125 |
-
|
| 126 |
|
| 127 |
def search_users(params: dict, limit: int = 10):
|
| 128 |
stats["queries"] += 1
|
|
@@ -141,31 +164,19 @@ def search_users(params: dict, limit: int = 10):
|
|
| 141 |
for c in conditions[1:]:
|
| 142 |
combined = combined & c
|
| 143 |
|
| 144 |
-
tbl = dataset.to_table(
|
| 145 |
-
filter=combined,
|
| 146 |
-
columns=["user_id", "username", "first_name", "last_name", "phone", "email", "status"]
|
| 147 |
-
)
|
| 148 |
elapsed = time.time() - t0
|
| 149 |
count = len(tbl)
|
| 150 |
-
if
|
| 151 |
return [], 0, elapsed
|
| 152 |
return tbl.to_pylist()[:limit], count, elapsed
|
| 153 |
|
| 154 |
-
|
| 155 |
-
app = FastAPI(title="Telegram DB API", description="859M Telegram users searchable by ID", version="1.0.0")
|
| 156 |
-
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
@app.on_event("startup")
|
| 160 |
-
async def startup():
|
| 161 |
-
init_dataset()
|
| 162 |
-
|
| 163 |
-
|
| 164 |
@app.get("/")
|
| 165 |
async def root():
|
| 166 |
return {
|
| 167 |
"name": "Telegram DB API",
|
| 168 |
"dataset": REPO_ID,
|
|
|
|
| 169 |
"endpoints": {
|
| 170 |
"/user/{user_id}": "Get full details by Telegram user ID",
|
| 171 |
"/search": "Search by username, phone, email, first_name, last_name",
|
|
@@ -173,15 +184,14 @@ async def root():
|
|
| 173 |
}
|
| 174 |
}
|
| 175 |
|
| 176 |
-
|
| 177 |
@app.get("/user/{user_id}")
|
| 178 |
async def get_user(user_id: int):
|
|
|
|
| 179 |
user, elapsed = query_user(user_id)
|
| 180 |
if user is None:
|
| 181 |
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
|
| 182 |
return {"found": True, "query_time_ms": round(elapsed * 1000, 2), "user": user}
|
| 183 |
|
| 184 |
-
|
| 185 |
@app.get("/search")
|
| 186 |
async def search(
|
| 187 |
username: str = Query(None),
|
|
@@ -191,6 +201,7 @@ async def search(
|
|
| 191 |
last_name: str = Query(None),
|
| 192 |
limit: int = Query(10, ge=1, le=100),
|
| 193 |
):
|
|
|
|
| 194 |
params = {k: v for k, v in {"username": username, "phone": phone, "email": email, "first_name": first_name, "last_name": last_name}.items() if v}
|
| 195 |
if not params:
|
| 196 |
raise HTTPException(status_code=400, detail="Provide at least one search parameter")
|
|
@@ -200,12 +211,13 @@ async def search(
|
|
| 200 |
raise HTTPException(status_code=404, detail="No users found")
|
| 201 |
return {"found": True, "count": total, "returned": len(rows), "query_time_ms": round(elapsed * 1000, 2), "users": rows}
|
| 202 |
|
| 203 |
-
|
| 204 |
@app.get("/health")
|
| 205 |
async def health():
|
| 206 |
uptime = round(time.time() - stats["startup_time"], 1) if stats["startup_time"] else 0
|
| 207 |
return {
|
| 208 |
-
"status": "ok",
|
|
|
|
|
|
|
| 209 |
"dataset": REPO_ID,
|
| 210 |
"cached": os.path.exists(CACHE_DIR),
|
| 211 |
"files": stats["total_files"],
|
|
|
|
| 1 |
import os
|
| 2 |
import time
|
| 3 |
import logging
|
| 4 |
+
import asyncio
|
| 5 |
import bisect
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
from fastapi import FastAPI, HTTPException, Query
|
| 8 |
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
from huggingface_hub import snapshot_download
|
|
|
|
| 19 |
|
| 20 |
dataset = None
|
| 21 |
user_id_index = None
|
| 22 |
+
is_ready = False
|
| 23 |
+
init_error = None
|
| 24 |
stats = {"startup_time": 0, "total_files": 0, "total_row_groups": 0, "total_rows": 0, "queries": 0}
|
| 25 |
|
| 26 |
+
ALL_COLS = ["user_id", "username", "first_name", "last_name", "phone", "email", "status", "linked_id", "linked_name", "linked_handle"]
|
| 27 |
+
|
| 28 |
def find_parquet(base):
|
| 29 |
files = []
|
| 30 |
for root, _, names in os.walk(base):
|
|
|
|
| 33 |
files.append(os.path.join(root, n))
|
| 34 |
return sorted(files)
|
| 35 |
|
|
|
|
| 36 |
class UserIdIndex:
|
| 37 |
def __init__(self):
|
| 38 |
self.entries = []
|
|
|
|
| 44 |
schema = arrow_dataset.schema
|
| 45 |
uid_idx = schema.get_field_index("user_id")
|
| 46 |
if uid_idx < 0:
|
| 47 |
+
logger.error("user_id column not found!")
|
| 48 |
return
|
| 49 |
for frag in arrow_dataset.get_fragments():
|
| 50 |
meta = frag.metadata
|
|
|
|
| 61 |
|
| 62 |
def find(self, user_id):
|
| 63 |
idx = bisect.bisect_right(self.keys, user_id) - 1
|
| 64 |
+
if idx >= 0 and idx < len(self.entries):
|
| 65 |
mn, mx, path, rg = self.entries[idx]
|
| 66 |
if mn <= user_id <= mx:
|
| 67 |
return path, rg
|
| 68 |
return None
|
| 69 |
|
|
|
|
| 70 |
def init_dataset():
|
| 71 |
+
global dataset, user_id_index, is_ready, init_error
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
+
try:
|
| 74 |
+
os.makedirs(CACHE_DIR, exist_ok=True)
|
| 75 |
+
files = find_parquet(CACHE_DIR)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
+
if not files:
|
| 78 |
+
logger.info("Downloading dataset (~8GB)...")
|
| 79 |
+
t0 = time.time()
|
| 80 |
+
snapshot_download(
|
| 81 |
+
repo_id=REPO_ID,
|
| 82 |
+
repo_type="dataset",
|
| 83 |
+
local_dir=CACHE_DIR,
|
| 84 |
+
local_dir_use_symlinks=False,
|
| 85 |
+
)
|
| 86 |
+
logger.info(f"Download completed in {time.time()-t0:.1f}s")
|
| 87 |
+
files = find_parquet(CACHE_DIR)
|
| 88 |
+
|
| 89 |
+
logger.info(f"Creating dataset from {len(files)} files...")
|
| 90 |
+
d = ds.dataset(files, format="parquet")
|
| 91 |
+
|
| 92 |
+
rg_count = sum(f.metadata.num_row_groups for f in d.get_fragments())
|
| 93 |
+
row_count = sum(f.count_rows() for f in d.get_fragments())
|
| 94 |
+
|
| 95 |
+
idx = UserIdIndex()
|
| 96 |
+
idx.build(d)
|
| 97 |
+
|
| 98 |
+
stats["total_files"] = len(files)
|
| 99 |
+
stats["total_row_groups"] = rg_count
|
| 100 |
+
stats["total_rows"] = row_count
|
| 101 |
+
stats["startup_time"] = time.time()
|
| 102 |
+
|
| 103 |
+
dataset = d
|
| 104 |
+
user_id_index = idx
|
| 105 |
+
is_ready = True
|
| 106 |
+
|
| 107 |
+
logger.info(f"Ready: {len(files)} files, {rg_count} row groups, {row_count:,} rows")
|
| 108 |
+
except Exception as e:
|
| 109 |
+
init_error = str(e)
|
| 110 |
+
logger.error(f"Init failed: {e}")
|
| 111 |
+
|
| 112 |
+
async def background_init():
|
| 113 |
+
loop = asyncio.get_event_loop()
|
| 114 |
+
await loop.run_in_executor(None, init_dataset)
|
| 115 |
+
|
| 116 |
+
@asynccontextmanager
|
| 117 |
+
async def lifespan(app):
|
| 118 |
+
asyncio.create_task(background_init())
|
| 119 |
+
yield
|
| 120 |
+
|
| 121 |
+
app = FastAPI(title="Telegram DB API", description="859M Telegram users searchable by ID", version="1.0.0", lifespan=lifespan)
|
| 122 |
+
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
| 123 |
|
| 124 |
+
def require_ready():
|
| 125 |
+
if not is_ready:
|
| 126 |
+
if init_error:
|
| 127 |
+
raise HTTPException(status_code=503, detail=f"Init error: {init_error}")
|
| 128 |
+
raise HTTPException(status_code=503, detail="Dataset loading, please retry in a moment")
|
| 129 |
|
| 130 |
def query_user(user_id: int):
|
| 131 |
stats["queries"] += 1
|
|
|
|
| 139 |
tbl = pf.read_row_group(rg_idx, columns=ALL_COLS)
|
| 140 |
tbl = tbl.filter(pc.equal(pc.field("user_id"), user_id))
|
| 141 |
if len(tbl):
|
| 142 |
+
return tbl.to_pylist()[0], time.time() - t0
|
|
|
|
| 143 |
except Exception as e:
|
| 144 |
logger.warning(f"Index lookup failed for {user_id}: {e}")
|
| 145 |
|
| 146 |
tbl = dataset.to_table(filter=pc.equal(pc.field("user_id"), user_id), columns=ALL_COLS)
|
| 147 |
elapsed = time.time() - t0
|
| 148 |
+
return (tbl.to_pylist()[0], elapsed) if len(tbl) else (None, elapsed)
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
def search_users(params: dict, limit: int = 10):
|
| 151 |
stats["queries"] += 1
|
|
|
|
| 164 |
for c in conditions[1:]:
|
| 165 |
combined = combined & c
|
| 166 |
|
| 167 |
+
tbl = dataset.to_table(filter=combined, columns=["user_id", "username", "first_name", "last_name", "phone", "email", "status"])
|
|
|
|
|
|
|
|
|
|
| 168 |
elapsed = time.time() - t0
|
| 169 |
count = len(tbl)
|
| 170 |
+
if not count:
|
| 171 |
return [], 0, elapsed
|
| 172 |
return tbl.to_pylist()[:limit], count, elapsed
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
@app.get("/")
|
| 175 |
async def root():
|
| 176 |
return {
|
| 177 |
"name": "Telegram DB API",
|
| 178 |
"dataset": REPO_ID,
|
| 179 |
+
"ready": is_ready,
|
| 180 |
"endpoints": {
|
| 181 |
"/user/{user_id}": "Get full details by Telegram user ID",
|
| 182 |
"/search": "Search by username, phone, email, first_name, last_name",
|
|
|
|
| 184 |
}
|
| 185 |
}
|
| 186 |
|
|
|
|
| 187 |
@app.get("/user/{user_id}")
|
| 188 |
async def get_user(user_id: int):
|
| 189 |
+
require_ready()
|
| 190 |
user, elapsed = query_user(user_id)
|
| 191 |
if user is None:
|
| 192 |
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
|
| 193 |
return {"found": True, "query_time_ms": round(elapsed * 1000, 2), "user": user}
|
| 194 |
|
|
|
|
| 195 |
@app.get("/search")
|
| 196 |
async def search(
|
| 197 |
username: str = Query(None),
|
|
|
|
| 201 |
last_name: str = Query(None),
|
| 202 |
limit: int = Query(10, ge=1, le=100),
|
| 203 |
):
|
| 204 |
+
require_ready()
|
| 205 |
params = {k: v for k, v in {"username": username, "phone": phone, "email": email, "first_name": first_name, "last_name": last_name}.items() if v}
|
| 206 |
if not params:
|
| 207 |
raise HTTPException(status_code=400, detail="Provide at least one search parameter")
|
|
|
|
| 211 |
raise HTTPException(status_code=404, detail="No users found")
|
| 212 |
return {"found": True, "count": total, "returned": len(rows), "query_time_ms": round(elapsed * 1000, 2), "users": rows}
|
| 213 |
|
|
|
|
| 214 |
@app.get("/health")
|
| 215 |
async def health():
|
| 216 |
uptime = round(time.time() - stats["startup_time"], 1) if stats["startup_time"] else 0
|
| 217 |
return {
|
| 218 |
+
"status": "loading" if not is_ready else ("error" if init_error else "ok"),
|
| 219 |
+
"ready": is_ready,
|
| 220 |
+
"error": init_error,
|
| 221 |
"dataset": REPO_ID,
|
| 222 |
"cached": os.path.exists(CACHE_DIR),
|
| 223 |
"files": stats["total_files"],
|