app.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. import io
  2. import os
  3. import logging
  4. from contextlib import asynccontextmanager
  5. import openpyxl
  6. import pyarrow as pa
  7. import pyarrow.csv as pa_csv
  8. import requests
  9. from fastapi import FastAPI, File, Form, HTTPException, UploadFile
  10. from fastapi.middleware.cors import CORSMiddleware
  11. from fastapi.responses import StreamingResponse
  12. from pyiceberg.catalog.rest import RestCatalog
  13. from pyiceberg.exceptions import NoSuchTableError
  14. logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
  15. logger = logging.getLogger(__name__)
  16. # ---------- Patch pyiceberg fsspec S3 factory for Aliyun OSS compatibility ----------
  17. # Aliyun OSS rejects aws-chunked encoding with UNSIGNED-PAYLOAD.
  18. # botocore must use payload_signing_enabled=True so that chunked uploads
  19. # send STREAMING-AWS4-HMAC-SHA256-PAYLOAD instead.
  20. import pyiceberg.io.fsspec as _fsspec_mod
  21. _orig_s3_factory = _fsspec_mod._s3
  22. def _oss_s3_factory(properties):
  23. """Wrap the original _s3 factory to inject payload_signing_enabled into botocore Config."""
  24. from s3fs import S3FileSystem
  25. from pyiceberg.io import (
  26. S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_SESSION_TOKEN,
  27. S3_REGION, S3_FORCE_VIRTUAL_ADDRESSING, S3_PROXY_URI, S3_CONNECT_TIMEOUT,
  28. S3_REQUEST_TIMEOUT, S3_ANONYMOUS, S3_PROFILE_NAME,
  29. AWS_ACCESS_KEY_ID, AWS_REGION, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, AWS_PROFILE_NAME,
  30. )
  31. from pyiceberg.utils.properties import get_first_property_value, strtobool
  32. client_kwargs = {
  33. "endpoint_url": properties.get(S3_ENDPOINT),
  34. "aws_access_key_id": get_first_property_value(properties, S3_ACCESS_KEY_ID, AWS_ACCESS_KEY_ID),
  35. "aws_secret_access_key": get_first_property_value(properties, S3_SECRET_ACCESS_KEY, AWS_SECRET_ACCESS_KEY),
  36. "aws_session_token": get_first_property_value(properties, S3_SESSION_TOKEN, AWS_SESSION_TOKEN),
  37. "region_name": get_first_property_value(properties, S3_REGION, AWS_REGION),
  38. }
  39. config_kwargs = {}
  40. if proxy_uri := properties.get(S3_PROXY_URI):
  41. config_kwargs["proxies"] = {"http": proxy_uri, "https": proxy_uri}
  42. if connect_timeout := properties.get(S3_CONNECT_TIMEOUT):
  43. config_kwargs["connect_timeout"] = float(connect_timeout)
  44. if request_timeout := properties.get(S3_REQUEST_TIMEOUT):
  45. config_kwargs["read_timeout"] = float(request_timeout)
  46. # OSS fix: virtual addressing + payload signing (avoids aws-chunked + UNSIGNED-PAYLOAD)
  47. config_kwargs["s3"] = {
  48. "addressing_style": "virtual",
  49. "payload_signing_enabled": True,
  50. }
  51. # botocore >= 1.35 adds flexible checksums that trigger aws-chunked encoding;
  52. # Aliyun OSS does not support this — disable automatic checksum calculation.
  53. config_kwargs["request_checksum_calculation"] = "when_required"
  54. config_kwargs["response_checksum_validation"] = "when_required"
  55. anon = strtobool(properties.get(S3_ANONYMOUS, "false")) if properties.get(S3_ANONYMOUS) else False
  56. s3_fs_kwargs = {"anon": anon, "client_kwargs": client_kwargs, "config_kwargs": config_kwargs}
  57. if profile_name := get_first_property_value(properties, S3_PROFILE_NAME, AWS_PROFILE_NAME):
  58. s3_fs_kwargs["profile"] = profile_name
  59. return S3FileSystem(**s3_fs_kwargs)
  60. _fsspec_mod.SCHEME_TO_FS["s3"] = _oss_s3_factory
  61. _fsspec_mod.SCHEME_TO_FS["s3a"] = _oss_s3_factory
  62. _fsspec_mod.SCHEME_TO_FS["s3n"] = _oss_s3_factory
  63. # ---------- Config (fetched from Java backend at startup) ----------
  64. BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8080")
  65. BACKEND_INTERNAL_HEADER = os.getenv("BACKEND_INTERNAL_HEADER", "X-Internal-Token")
  66. BACKEND_INTERNAL_TOKEN = os.getenv("BACKEND_INTERNAL_TOKEN", "").strip()
  67. CFG: dict[str, str] = {}
  68. def _fetch_config() -> None:
  69. """Pull config from Spring Boot backend (application.yml is the single source of truth)."""
  70. global CFG
  71. headers = {}
  72. if BACKEND_INTERNAL_TOKEN:
  73. headers[BACKEND_INTERNAL_HEADER] = BACKEND_INTERNAL_TOKEN
  74. resp = requests.get(
  75. f"{BACKEND_URL}/api/data/internal/iceberg-config",
  76. headers=headers or None,
  77. timeout=10,
  78. )
  79. resp.raise_for_status()
  80. CFG = resp.json()
  81. logger.info("Config loaded from backend: polaris_host=%s, catalog=%s, bucket=%s",
  82. CFG.get("polaris_host"), CFG.get("polaris_catalog"), CFG.get("oss_bucket"))
  83. def get_catalog() -> RestCatalog:
  84. if not CFG:
  85. raise RuntimeError("Config not loaded — is the Java backend running?")
  86. props = {
  87. "uri": f"{CFG['polaris_host']}/api/catalog",
  88. "credential": f"{CFG['polaris_client_id']}:{CFG['polaris_client_secret']}",
  89. "warehouse": CFG["polaris_catalog"],
  90. "header.Polaris-Realm": CFG["polaris_realm"],
  91. "scope": "PRINCIPAL_ROLE:ALL",
  92. "s3.region": CFG["oss_region"],
  93. # Use fsspec/s3fs (botocore) instead of PyArrow's native S3 (AWS C++ SDK).
  94. # PyArrow sends aws-chunked encoding with UNSIGNED-PAYLOAD which Aliyun OSS rejects.
  95. "py-io-impl": "pyiceberg.io.fsspec.FsspecFileIO",
  96. }
  97. endpoint = CFG.get("oss_endpoint", "")
  98. if endpoint:
  99. props["s3.endpoint"] = endpoint
  100. props["s3.force-virtual-addressing"] = "true"
  101. if CFG.get("oss_access_key_id"):
  102. props["s3.access-key-id"] = CFG["oss_access_key_id"]
  103. if CFG.get("oss_access_key_secret"):
  104. props["s3.secret-access-key"] = CFG["oss_access_key_secret"]
  105. catalog = RestCatalog(name="polaris", **props)
  106. catalog._session.headers.pop("X-Iceberg-Access-Delegation", None)
  107. return catalog
  108. def _ensure_namespace(catalog: RestCatalog, database: str) -> None:
  109. bucket = CFG.get("oss_bucket", "")
  110. catalog_name = CFG.get("polaris_catalog", "")
  111. expected_location = f"s3://{bucket}/{catalog_name}/{database}" if bucket else ""
  112. try:
  113. ns_props = {"location": expected_location} if expected_location else {}
  114. catalog.create_namespace(database, ns_props)
  115. except Exception:
  116. # Namespace already exists — make sure its location points to the correct bucket
  117. if expected_location:
  118. try:
  119. catalog.update_namespace_properties(database, updates={"location": expected_location})
  120. except Exception:
  121. pass
  122. def _write(catalog: RestCatalog, database: str, table: str, arrow_table: pa.Table, mode: str) -> int:
  123. identifier = f"{database}.{table}"
  124. _ensure_namespace(catalog, database)
  125. try:
  126. tbl = catalog.load_table(identifier)
  127. if mode == "overwrite":
  128. tbl.overwrite(arrow_table)
  129. else:
  130. tbl.append(arrow_table)
  131. except NoSuchTableError:
  132. tbl = catalog.create_table(identifier=identifier, schema=arrow_table.schema)
  133. tbl.append(arrow_table)
  134. return len(arrow_table)
  135. # ---------- Startup: fetch config & ensure Polaris grants ----------
  136. def _get_management_token() -> str:
  137. resp = requests.post(
  138. f"{CFG['polaris_host']}/api/catalog/v1/oauth/tokens",
  139. headers={"Polaris-Realm": CFG["polaris_realm"]},
  140. data={
  141. "grant_type": "client_credentials",
  142. "client_id": CFG["polaris_client_id"],
  143. "client_secret": CFG["polaris_client_secret"],
  144. "scope": "PRINCIPAL_ROLE:ALL",
  145. },
  146. timeout=10,
  147. )
  148. resp.raise_for_status()
  149. return resp.json()["access_token"]
  150. def _ensure_catalog_grants(headers: dict) -> None:
  151. catalog_name = CFG["polaris_catalog"]
  152. url = f"{CFG['polaris_host']}/api/management/v1/catalogs/{catalog_name}/catalog-roles/catalog_admin/grants"
  153. resp = requests.put(url, headers=headers, json={"type": "catalog", "privilege": "CATALOG_MANAGE_CONTENT"}, timeout=10)
  154. already_exists = resp.status_code == 409 or (resp.status_code == 500 and "duplicate key" in resp.text)
  155. if resp.status_code == 201:
  156. logger.info("Granted CATALOG_MANAGE_CONTENT to catalog_admin on %s", catalog_name)
  157. elif already_exists:
  158. logger.info("CATALOG_MANAGE_CONTENT already granted on %s", catalog_name)
  159. else:
  160. logger.warning("Grant response %s: %s", resp.status_code, resp.text)
  161. def _ensure_catalog_storage_credentials(headers: dict) -> None:
  162. ak = CFG.get("oss_access_key_id", "")
  163. sk = CFG.get("oss_access_key_secret", "")
  164. if not ak or not sk:
  165. logger.warning("OSS credentials not configured — skipping credential storage")
  166. return
  167. catalog_name = CFG["polaris_catalog"]
  168. bucket = CFG.get("oss_bucket", "")
  169. base_location = f"s3://{bucket}/{catalog_name}" if bucket else ""
  170. get_resp = requests.get(
  171. f"{CFG['polaris_host']}/api/management/v1/catalogs/{catalog_name}",
  172. headers=headers, timeout=10,
  173. )
  174. get_resp.raise_for_status()
  175. catalog_info = get_resp.json()
  176. entity_version = catalog_info["entityVersion"]
  177. storage = catalog_info.get("storageConfigInfo", {})
  178. current_props = catalog_info.get("properties", {})
  179. if (storage.get("accessKeyId") == ak
  180. and (not base_location or current_props.get("default-base-location") == base_location)):
  181. logger.info("OSS credentials already stored in Polaris catalog %s", catalog_name)
  182. return
  183. updated_props = {**current_props}
  184. if base_location:
  185. updated_props["default-base-location"] = base_location
  186. # Ensure DROP with purge can clean up underlying OSS objects for this catalog.
  187. updated_props["polaris.config.drop-with-purge.enabled"] = "true"
  188. update_body = {
  189. "currentEntityVersion": entity_version,
  190. "properties": updated_props,
  191. "storageConfigInfo": {
  192. "storageType": storage.get("storageType", "S3"),
  193. "allowedLocations": [f"s3://{bucket}/"] if bucket else storage.get("allowedLocations", []),
  194. "region": storage.get("region", CFG.get("oss_region", "")),
  195. "endpoint": storage.get("endpoint", CFG.get("oss_endpoint", "")),
  196. "pathStyleAccess": storage.get("pathStyleAccess", False),
  197. "stsUnavailable": True,
  198. "accessKeyId": ak,
  199. "secretAccessKey": sk,
  200. },
  201. }
  202. put_resp = requests.put(
  203. f"{CFG['polaris_host']}/api/management/v1/catalogs/{catalog_name}",
  204. headers=headers, json=update_body, timeout=10,
  205. )
  206. if put_resp.status_code == 200:
  207. logger.info("Stored OSS credentials in Polaris catalog %s", catalog_name)
  208. else:
  209. logger.warning("Catalog credential update %s: %s", put_resp.status_code, put_resp.text)
  210. @asynccontextmanager
  211. async def lifespan(app: FastAPI):
  212. import asyncio
  213. # Config fetch must succeed — retry until Java backend is reachable
  214. for attempt in range(30):
  215. try:
  216. _fetch_config()
  217. break
  218. except Exception as e:
  219. logger.warning("Config fetch attempt %d failed: %s — retrying in 2s", attempt + 1, e)
  220. await asyncio.sleep(2)
  221. else:
  222. logger.error("Could not fetch config from backend after 30 attempts")
  223. if CFG:
  224. try:
  225. token = _get_management_token()
  226. headers = {
  227. "Authorization": f"Bearer {token}",
  228. "Polaris-Realm": CFG["polaris_realm"],
  229. "Content-Type": "application/json",
  230. }
  231. _ensure_catalog_grants(headers)
  232. _ensure_catalog_storage_credentials(headers)
  233. except Exception as e:
  234. logger.warning("Polaris setup failed (will retry on next restart): %s", e)
  235. yield
  236. # ---------- App ----------
  237. app = FastAPI(title="Iceberg Data Service", lifespan=lifespan)
  238. app.add_middleware(
  239. CORSMiddleware,
  240. allow_origins=["*"],
  241. allow_methods=["*"],
  242. allow_headers=["*"],
  243. )
  244. @app.get("/health")
  245. def health():
  246. return {"status": "ok"}
  247. # ---------- Excel import endpoint ----------
  248. @app.post("/import/excel")
  249. async def import_excel(
  250. database: str,
  251. file: UploadFile = File(...),
  252. mode: str = Form(default="append"),
  253. ):
  254. try:
  255. content = await file.read()
  256. wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
  257. catalog = get_catalog()
  258. results = []
  259. seen_names: set[str] = set()
  260. for sheet_name in wb.sheetnames:
  261. # 检测同一 Excel 内重复 Sheet 名(Excel 本身禁止,但做防御性检查)
  262. name_key = sheet_name.strip().lower()
  263. if name_key in seen_names:
  264. results.append({"table": sheet_name, "error": f"Excel 中存在重复 Sheet 名「{sheet_name}」"})
  265. continue
  266. seen_names.add(name_key)
  267. ws = wb[sheet_name]
  268. rows_iter = ws.iter_rows(values_only=True)
  269. try:
  270. header_row = next(rows_iter)
  271. except StopIteration:
  272. results.append({"table": sheet_name, "skipped": True, "reason": "空 sheet"})
  273. continue
  274. headers = [str(h) if h is not None else f"col_{i}" for i, h in enumerate(header_row)]
  275. if not any(h for h in headers):
  276. results.append({"table": sheet_name, "skipped": True, "reason": "空表头"})
  277. continue
  278. # 检测目标库中是否已存在同名表(overwrite 模式允许覆盖)
  279. if mode != "overwrite":
  280. try:
  281. catalog.load_table(f"{database}.{sheet_name}")
  282. results.append({"table": sheet_name, "error": f"表「{sheet_name}」已存在,请先删除后再导入"})
  283. continue
  284. except NoSuchTableError:
  285. pass
  286. data_rows = list(rows_iter)
  287. if not data_rows:
  288. arrays = [pa.array([], type=pa.string()) for _ in headers]
  289. else:
  290. cols = [[row[i] if i < len(row) else None for row in data_rows] for i in range(len(headers))]
  291. arrays = [pa.array(col) for col in cols]
  292. arrow_table = pa.table(dict(zip(headers, arrays)))
  293. try:
  294. row_count = _write(catalog, database, sheet_name, arrow_table, mode)
  295. results.append({"table": sheet_name, "rows": row_count, "columns": len(headers)})
  296. except Exception as e:
  297. results.append({"table": sheet_name, "columns": len(headers), "error": str(e)})
  298. wb.close()
  299. return {"success": True, "tables": results}
  300. except Exception as e:
  301. logger.error("Excel import error", exc_info=True)
  302. raise HTTPException(status_code=500, detail=str(e))
  303. @app.post("/import/csv")
  304. async def import_csv(
  305. database: str,
  306. table: str,
  307. file: UploadFile = File(...),
  308. mode: str = Form(default="append"),
  309. ):
  310. try:
  311. content = await file.read()
  312. arrow_table = pa_csv.read_csv(io.BytesIO(content))
  313. rows = _write(get_catalog(), database, table, arrow_table, mode)
  314. return {"success": True, "rows": rows, "columns": len(arrow_table.schema)}
  315. except Exception as e:
  316. logger.error("CSV import error", exc_info=True)
  317. raise HTTPException(status_code=500, detail=str(e))
  318. @app.get("/export/csv")
  319. async def export_csv(database: str, table: str):
  320. try:
  321. catalog = get_catalog()
  322. tbl = catalog.load_table(f"{database}.{table}")
  323. arrow_table = tbl.scan().to_arrow()
  324. buf = io.BytesIO()
  325. # Add UTF-8 BOM to make Excel happy with Chinese characters
  326. buf.write(b"\xef\xbb\xbf")
  327. pa_csv.write_csv(arrow_table, buf)
  328. buf.seek(0)
  329. filename = f"{database}_{table}.csv"
  330. return StreamingResponse(
  331. buf,
  332. media_type="text/csv; charset=utf-8",
  333. headers={"Content-Disposition": f'attachment; filename="{filename}"'},
  334. )
  335. except Exception as e:
  336. logger.error("CSV export error", exc_info=True)
  337. raise HTTPException(status_code=500, detail=str(e))
  338. # ---------- LLM Function Calling 查询接口 ----------
  339. import pyarrow.compute as pc
  340. from typing import Optional
  341. def _safe_json(val):
  342. """将 PyArrow .as_py() 的值转换为 JSON 可序列化的 Python 类型。"""
  343. if val is None:
  344. return None
  345. if isinstance(val, (int, float, bool, str)):
  346. return val
  347. # date / datetime / Decimal 等 → str
  348. return str(val)
  349. def _to_column_flat(arrow_table: pa.Table, column: str) -> pa.Array:
  350. """取出指定列并合并 chunk,返回 pa.Array。"""
  351. col = arrow_table.column(column)
  352. return col.combine_chunks() if isinstance(col, pa.ChunkedArray) else col
  353. @app.get("/preview")
  354. async def preview(database: str, table: str, limit: int = 5):
  355. """
  356. query_sample_data:返回表的前 N 行样本数据。
  357. 响应:{"table": str, "columns": [...], "rows": [[...], ...], "total_returned": int}
  358. """
  359. limit = min(max(limit, 1), 500)
  360. try:
  361. tbl = get_catalog().load_table(f"{database}.{table}")
  362. arrow_table = tbl.scan(limit=limit).to_arrow()
  363. columns = arrow_table.schema.names
  364. rows = [
  365. [_safe_json(arrow_table.column(c)[i].as_py()) for c in columns]
  366. for i in range(len(arrow_table))
  367. ]
  368. return {"table": table, "columns": columns, "rows": rows, "total_returned": len(arrow_table)}
  369. except NoSuchTableError:
  370. raise HTTPException(status_code=404, detail=f"表 {database}.{table} 不存在")
  371. except Exception as e:
  372. logger.error("preview error", exc_info=True)
  373. raise HTTPException(status_code=500, detail=str(e))
  374. @app.get("/distinct")
  375. async def distinct_values(database: str, table: str, column: str, limit: int = 20):
  376. """
  377. query_distinct_values:返回指定列的不同值及出现次数(按频次降序)。
  378. 响应:{"column": str, "total_distinct": int, "values": [{"val": ..., "count": int}, ...]}
  379. """
  380. limit = min(max(limit, 1), 500)
  381. try:
  382. tbl = get_catalog().load_table(f"{database}.{table}")
  383. arrow_table = tbl.scan(selected_fields=(column,)).to_arrow()
  384. col_flat = _to_column_flat(arrow_table, column)
  385. total_distinct = pc.count_distinct(col_flat, mode="only_valid").as_py()
  386. # value_counts() → StructArray {values, counts};建表后排序
  387. vc = col_flat.value_counts()
  388. vc_table = pa.table({
  389. "val": vc.field("values"),
  390. "cnt": vc.field("counts"),
  391. })
  392. sorted_vc = vc_table.sort_by([("cnt", "descending")]).slice(0, limit)
  393. values = [
  394. {"val": _safe_json(sorted_vc.column("val")[i].as_py()),
  395. "count": sorted_vc.column("cnt")[i].as_py()}
  396. for i in range(len(sorted_vc))
  397. ]
  398. return {"column": column, "total_distinct": total_distinct, "values": values}
  399. except NoSuchTableError:
  400. raise HTTPException(status_code=404, detail=f"表 {database}.{table} 不存在")
  401. except Exception as e:
  402. logger.error("distinct error", exc_info=True)
  403. raise HTTPException(status_code=500, detail=str(e))
  404. @app.get("/statistics")
  405. async def statistics(database: str, table: str, column: Optional[str] = None):
  406. """
  407. query_statistics:返回表的行数;若指定列则额外返回 min/max/distinct_count/null_count。
  408. 响应:{"table": str, "total_rows": int, "column"?: str, "min"?: ..., "max"?: ...,
  409. "distinct_count"?: int, "null_count"?: int}
  410. """
  411. try:
  412. tbl = get_catalog().load_table(f"{database}.{table}")
  413. result: dict = {"table": table}
  414. if column:
  415. arrow_table = tbl.scan(selected_fields=(column,)).to_arrow()
  416. result["total_rows"] = len(arrow_table)
  417. result["column"] = column
  418. col_flat = _to_column_flat(arrow_table, column)
  419. try:
  420. result["min"] = _safe_json(pc.min(col_flat).as_py())
  421. result["max"] = _safe_json(pc.max(col_flat).as_py())
  422. result["distinct_count"] = pc.count_distinct(col_flat, mode="only_valid").as_py()
  423. result["null_count"] = col_flat.null_count
  424. except Exception as stat_err:
  425. result["stats_error"] = str(stat_err)
  426. else:
  427. # 仅统计行数:用 metadata 统计,避免全量扫描
  428. result["total_rows"] = tbl.scan().count()
  429. return result
  430. except NoSuchTableError:
  431. raise HTTPException(status_code=404, detail=f"表 {database}.{table} 不存在")
  432. except Exception as e:
  433. logger.error("statistics error", exc_info=True)
  434. raise HTTPException(status_code=500, detail=str(e))