d1_cleanup.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. """Periodic Cloudflare D1 cleanup for cfmail storage tables."""
  2. from __future__ import annotations
  3. import argparse
  4. import json
  5. import os
  6. import time
  7. from typing import Any
  8. from urllib.error import HTTPError, URLError
  9. from urllib.request import Request, urlopen
  10. from .common import now
  11. DEFAULT_D1_DATABASE_ID = ""
  12. DEFAULT_D1_MAIL_RETENTION_HOURS = 2
  13. DEFAULT_D1_ADDRESS_RETENTION_HOURS = 24
  14. DEFAULT_D1_CLEANUP_BATCH_SIZE = 5000
  15. _QUERY_TIMEOUT_SECONDS = 30
  16. _CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4"
  17. _missing_credentials_warned = False
  18. class D1CleanupError(RuntimeError):
  19. """Raised when the D1 query API returns a non-recoverable error."""
  20. class D1TableMissingError(D1CleanupError):
  21. """Raised when the target D1 table does not exist."""
  22. def _warning(message: str) -> None:
  23. print(f"[{now()}] [d1_cleanup] warning: {message}")
  24. def _credentials_from_env() -> tuple[str, str, str] | None:
  25. global _missing_credentials_warned
  26. auth_email = str(os.getenv("ZHUCE6_CFMAIL_CF_AUTH_EMAIL", "")).strip()
  27. auth_key = str(os.getenv("ZHUCE6_CFMAIL_CF_AUTH_KEY", "")).strip()
  28. account_id = str(os.getenv("ZHUCE6_CFMAIL_CF_ACCOUNT_ID", "")).strip()
  29. if auth_email and auth_key and account_id:
  30. _missing_credentials_warned = False
  31. return auth_email, auth_key, account_id
  32. if not _missing_credentials_warned:
  33. _warning(
  34. "missing Cloudflare credentials, skip cleanup "
  35. "(need ZHUCE6_CFMAIL_CF_AUTH_EMAIL / ZHUCE6_CFMAIL_CF_AUTH_KEY / ZHUCE6_CFMAIL_CF_ACCOUNT_ID)"
  36. )
  37. _missing_credentials_warned = True
  38. return None
  39. def _error_messages(payload: dict[str, Any]) -> list[str]:
  40. messages: list[str] = []
  41. for bucket in ("errors", "messages"):
  42. items = payload.get(bucket)
  43. if not isinstance(items, list):
  44. continue
  45. for item in items:
  46. if isinstance(item, dict):
  47. message = str(item.get("message") or "").strip()
  48. if message:
  49. messages.append(message)
  50. result_items = payload.get("result")
  51. if isinstance(result_items, list):
  52. for result in result_items:
  53. if not isinstance(result, dict):
  54. continue
  55. if bool(result.get("success", True)):
  56. continue
  57. message = str(result.get("error") or result.get("message") or "").strip()
  58. if message:
  59. messages.append(message)
  60. return messages
  61. def _raise_for_payload(payload: dict[str, Any]) -> None:
  62. messages = _error_messages(payload)
  63. text = " | ".join(messages) if messages else json.dumps(payload, ensure_ascii=False)
  64. lowered = text.lower()
  65. if "no such table" in lowered or "sqlite_error" in lowered:
  66. raise D1TableMissingError(text)
  67. raise D1CleanupError(text)
  68. def _query(database_id: str, sql: str, params: list[Any] | None = None) -> dict[str, Any]:
  69. credentials = _credentials_from_env()
  70. if credentials is None:
  71. raise D1CleanupError("missing_cloudflare_credentials")
  72. auth_email, auth_key, account_id = credentials
  73. url = f"{_CLOUDFLARE_API_BASE}/accounts/{account_id}/d1/database/{database_id}/query"
  74. body = {"sql": sql}
  75. if params:
  76. body["params"] = params
  77. request = Request(
  78. url,
  79. data=json.dumps(body).encode("utf-8"),
  80. headers={
  81. "Content-Type": "application/json",
  82. "X-Auth-Email": auth_email,
  83. "X-Auth-Key": auth_key,
  84. },
  85. method="POST",
  86. )
  87. try:
  88. with urlopen(request, timeout=_QUERY_TIMEOUT_SECONDS) as response:
  89. payload = json.loads(response.read().decode("utf-8"))
  90. except HTTPError as exc:
  91. try:
  92. detail = exc.read().decode("utf-8", errors="replace")
  93. except Exception:
  94. detail = str(exc)
  95. try:
  96. payload = json.loads(detail)
  97. except json.JSONDecodeError:
  98. payload = None
  99. if isinstance(payload, dict):
  100. _raise_for_payload(payload)
  101. raise D1CleanupError(f"http {exc.code}: {detail}") from exc
  102. except URLError as exc:
  103. raise D1CleanupError(f"network error: {exc}") from exc
  104. except json.JSONDecodeError as exc:
  105. raise D1CleanupError(f"invalid json response: {exc}") from exc
  106. if not bool(payload.get("success", False)):
  107. _raise_for_payload(payload)
  108. return payload
  109. def _first_result(payload: dict[str, Any]) -> dict[str, Any]:
  110. results = payload.get("result")
  111. if not isinstance(results, list) or not results or not isinstance(results[0], dict):
  112. raise D1CleanupError("missing result payload")
  113. result = results[0]
  114. if not bool(result.get("success", True)):
  115. _raise_for_payload(payload)
  116. return result
  117. def _query_once(database_id: str, sql: str, params: list[Any] | None = None) -> tuple[list[dict[str, Any]], dict[str, Any]]:
  118. payload = _query(database_id, sql, params=params)
  119. result = _first_result(payload)
  120. rows = result.get("results")
  121. if not isinstance(rows, list):
  122. rows = []
  123. meta = result.get("meta")
  124. if not isinstance(meta, dict):
  125. meta = {}
  126. normalized_rows = [row for row in rows if isinstance(row, dict)]
  127. return normalized_rows, meta
  128. def _count_rows(database_id: str, table: str) -> tuple[int, int | None]:
  129. rows, meta = _query_once(database_id, f"SELECT COUNT(*) AS count FROM {table}")
  130. count = 0
  131. if rows:
  132. try:
  133. count = int(rows[0].get("count") or 0)
  134. except Exception:
  135. count = 0
  136. size_after = meta.get("size_after")
  137. try:
  138. return count, int(size_after) if size_after is not None else None
  139. except Exception:
  140. return count, None
  141. def _delete_in_batches(database_id: str, table: str, retention_hours: int, batch_size: int) -> tuple[int, int | None]:
  142. total_deleted = 0
  143. latest_size_after: int | None = None
  144. safe_retention = max(0, int(retention_hours))
  145. safe_batch_size = max(1, int(batch_size))
  146. sql = (
  147. f"DELETE FROM {table} "
  148. f"WHERE created_at < datetime('now', '-{safe_retention} hours') "
  149. f"LIMIT {safe_batch_size}"
  150. )
  151. while True:
  152. _rows, meta = _query_once(database_id, sql)
  153. changes_raw = meta.get("changes")
  154. try:
  155. changes = int(changes_raw or 0)
  156. except Exception:
  157. changes = 0
  158. size_after = meta.get("size_after")
  159. try:
  160. latest_size_after = int(size_after) if size_after is not None else latest_size_after
  161. except Exception:
  162. pass
  163. if changes <= 0:
  164. break
  165. total_deleted += changes
  166. return total_deleted, latest_size_after
  167. def _final_size_after(database_id: str) -> int | None:
  168. _rows, meta = _query_once(database_id, "SELECT 1 AS ok")
  169. size_after = meta.get("size_after")
  170. try:
  171. return int(size_after) if size_after is not None else None
  172. except Exception:
  173. return None
  174. def d1_cleanup_once(
  175. database_id: str = DEFAULT_D1_DATABASE_ID,
  176. mail_retention_hours: int = DEFAULT_D1_MAIL_RETENTION_HOURS,
  177. address_retention_hours: int = DEFAULT_D1_ADDRESS_RETENTION_HOURS,
  178. batch_size: int = DEFAULT_D1_CLEANUP_BATCH_SIZE,
  179. ) -> dict[str, object]:
  180. summary: dict[str, object] = {
  181. "deleted_mails": 0,
  182. "deleted_addresses": 0,
  183. "deleted_senders": 0,
  184. "size_after_bytes": None,
  185. "skipped_reason": None,
  186. }
  187. normalized_database_id = str(database_id or "").strip()
  188. if not normalized_database_id:
  189. summary["skipped_reason"] = "missing_database_id"
  190. return summary
  191. if _credentials_from_env() is None:
  192. summary["skipped_reason"] = "missing_cloudflare_credentials"
  193. return summary
  194. size_after_bytes: int | None = None
  195. try:
  196. raw_mails_count, size_after_bytes = _count_rows(normalized_database_id, "raw_mails")
  197. except D1TableMissingError:
  198. _warning("table raw_mails not found, skip count")
  199. raw_mails_count = 0
  200. try:
  201. address_count, count_size_after = _count_rows(normalized_database_id, "address")
  202. if count_size_after is not None:
  203. size_after_bytes = count_size_after
  204. except D1TableMissingError:
  205. _warning("table address not found, skip count")
  206. address_count = 0
  207. if raw_mails_count == 0 and address_count == 0:
  208. summary["size_after_bytes"] = size_after_bytes
  209. summary["skipped_reason"] = "nothing_to_clean"
  210. print(f"[{now()}] [d1_cleanup] nothing to clean")
  211. return summary
  212. try:
  213. deleted_mails, delete_size_after = _delete_in_batches(
  214. normalized_database_id,
  215. "raw_mails",
  216. retention_hours=mail_retention_hours,
  217. batch_size=batch_size,
  218. )
  219. summary["deleted_mails"] = deleted_mails
  220. if delete_size_after is not None:
  221. size_after_bytes = delete_size_after
  222. except D1TableMissingError:
  223. _warning("table raw_mails not found, skip cleanup")
  224. try:
  225. deleted_addresses, delete_size_after = _delete_in_batches(
  226. normalized_database_id,
  227. "address",
  228. retention_hours=address_retention_hours,
  229. batch_size=batch_size,
  230. )
  231. summary["deleted_addresses"] = deleted_addresses
  232. if delete_size_after is not None:
  233. size_after_bytes = delete_size_after
  234. except D1TableMissingError:
  235. _warning("table address not found, skip cleanup")
  236. try:
  237. deleted_senders, delete_size_after = _delete_in_batches(
  238. normalized_database_id,
  239. "address_sender",
  240. retention_hours=address_retention_hours,
  241. batch_size=batch_size,
  242. )
  243. summary["deleted_senders"] = deleted_senders
  244. if delete_size_after is not None:
  245. size_after_bytes = delete_size_after
  246. except D1TableMissingError:
  247. _warning("table address_sender not found, skip cleanup")
  248. try:
  249. final_size_after = _final_size_after(normalized_database_id)
  250. if final_size_after is not None:
  251. size_after_bytes = final_size_after
  252. except D1CleanupError as exc:
  253. _warning(f"final size check failed: {exc}")
  254. summary["size_after_bytes"] = size_after_bytes
  255. size_mb_text = "unknown"
  256. if isinstance(size_after_bytes, int):
  257. size_mb_text = f"{size_after_bytes / (1024 * 1024):.1f}MB"
  258. print(
  259. f"[{now()}] [d1_cleanup] 清理完成 | raw_mails=-{summary['deleted_mails']} "
  260. f"| address=-{summary['deleted_addresses']} | address_sender=-{summary['deleted_senders']} "
  261. f"| size={size_mb_text}"
  262. )
  263. return summary
  264. def main() -> None:
  265. parser = argparse.ArgumentParser(description="清理 cfmail Cloudflare D1 历史数据")
  266. parser.add_argument("--once", action="store_true", help="只执行一轮")
  267. parser.add_argument("--interval", type=int, default=1800, help="清理间隔秒数")
  268. parser.add_argument("--database-id", default=DEFAULT_D1_DATABASE_ID, help="Cloudflare D1 database id")
  269. parser.add_argument(
  270. "--mail-retention-hours",
  271. type=int,
  272. default=DEFAULT_D1_MAIL_RETENTION_HOURS,
  273. help="raw_mails 保留小时数",
  274. )
  275. parser.add_argument(
  276. "--address-retention-hours",
  277. type=int,
  278. default=DEFAULT_D1_ADDRESS_RETENTION_HOURS,
  279. help="address / address_sender 保留小时数",
  280. )
  281. args = parser.parse_args()
  282. interval = max(1, int(args.interval))
  283. while True:
  284. started_at = time.time()
  285. try:
  286. d1_cleanup_once(
  287. database_id=str(args.database_id).strip(),
  288. mail_retention_hours=int(args.mail_retention_hours),
  289. address_retention_hours=int(args.address_retention_hours),
  290. )
  291. except Exception as exc:
  292. _warning(f"cleanup failed: {exc}")
  293. elapsed = time.time() - started_at
  294. if args.once:
  295. break
  296. time.sleep(max(0, interval - elapsed))
  297. if __name__ == "__main__":
  298. main()