storage.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684
  1. """MySQL state and audit storage for real-time control."""
  2. from __future__ import annotations
  3. import os
  4. import json
  5. from contextlib import contextmanager
  6. from datetime import date, datetime
  7. from pathlib import Path
  8. from typing import Any, Iterator
  9. import pymysql
  10. import pymysql.cursors
  11. ROOT = Path(__file__).resolve().parent
  12. def connect() -> pymysql.Connection:
  13. required = ["DB_HOST", "DB_USER", "DB_NAME"]
  14. missing = [key for key in required if not os.getenv(key)]
  15. if missing:
  16. raise RuntimeError(f"Missing database environment variables: {', '.join(missing)}")
  17. return pymysql.connect(
  18. host=os.environ["DB_HOST"],
  19. port=int(os.getenv("DB_PORT", "3306")),
  20. user=os.environ["DB_USER"],
  21. password=os.getenv("DB_PASSWORD", ""),
  22. database=os.environ["DB_NAME"],
  23. charset="utf8mb4",
  24. cursorclass=pymysql.cursors.DictCursor,
  25. autocommit=True,
  26. connect_timeout=int(os.getenv("DB_CONNECT_TIMEOUT", "10")),
  27. read_timeout=int(os.getenv("DB_READ_TIMEOUT", "60")),
  28. write_timeout=int(os.getenv("DB_WRITE_TIMEOUT", "60")),
  29. )
  30. def initialize_schema() -> None:
  31. statements = [
  32. statement.strip()
  33. for statement in (ROOT / "schema.sql").read_text(encoding="utf-8").split(";")
  34. if statement.strip()
  35. ]
  36. connection = connect()
  37. try:
  38. with connection.cursor() as cursor:
  39. for statement in statements:
  40. cursor.execute(statement)
  41. cursor.execute(
  42. """
  43. SELECT COLUMN_NAME
  44. FROM information_schema.COLUMNS
  45. WHERE TABLE_SCHEMA=%s
  46. AND TABLE_NAME='realtime_control_ad_state'
  47. """,
  48. (os.environ["DB_NAME"],),
  49. )
  50. existing_columns = {row["COLUMN_NAME"] for row in cursor.fetchall()}
  51. migrations = {
  52. "initial_base_bid_fen": "INT DEFAULT NULL",
  53. "last_roi_scaled_at": "DATETIME DEFAULT NULL",
  54. "operator_pause_mode": "VARCHAR(32) DEFAULT NULL",
  55. "operator_resume_at": "DATETIME DEFAULT NULL",
  56. "operator_command_id": "VARCHAR(64) DEFAULT NULL",
  57. "operator_paused_from_status": "VARCHAR(50) DEFAULT NULL",
  58. "operator_paused_at": "DATETIME DEFAULT NULL",
  59. }
  60. for column, definition in migrations.items():
  61. if column not in existing_columns:
  62. cursor.execute(
  63. f"ALTER TABLE realtime_control_ad_state "
  64. f"ADD COLUMN {column} {definition}"
  65. )
  66. cursor.execute(
  67. """
  68. UPDATE realtime_control_ad_state
  69. SET initial_base_bid_fen=base_bid_fen
  70. WHERE initial_base_bid_fen IS NULL
  71. """
  72. )
  73. cursor.execute(
  74. """
  75. SELECT INDEX_NAME
  76. FROM information_schema.STATISTICS
  77. WHERE TABLE_SCHEMA=%s
  78. AND TABLE_NAME='realtime_control_ad_state'
  79. AND INDEX_NAME='idx_operator_pause'
  80. """,
  81. (os.environ["DB_NAME"],),
  82. )
  83. if not cursor.fetchone():
  84. cursor.execute(
  85. """
  86. CREATE INDEX idx_operator_pause
  87. ON realtime_control_ad_state
  88. (operator_pause_mode, operator_resume_at)
  89. """
  90. )
  91. roi_migrations = {
  92. "roi_metric_run": {
  93. "fission_parameter_version": "VARCHAR(64) DEFAULT NULL",
  94. "fission_cohort_date": "DATE DEFAULT NULL",
  95. },
  96. "roi_entity_snapshot": {
  97. "conversion_goal": "VARCHAR(255) DEFAULT NULL",
  98. "fission_parameter_version": "VARCHAR(64) DEFAULT NULL",
  99. "fission_cohort_date": "VARCHAR(8) DEFAULT NULL",
  100. "fission_multiplier_vs_t0": "DECIMAL(18,8) DEFAULT NULL",
  101. "fission_match_level": "VARCHAR(64) DEFAULT NULL",
  102. "fission_source": "VARCHAR(512) DEFAULT NULL",
  103. "actual_total_revenue": "DECIMAL(20,4) DEFAULT NULL",
  104. "actual_roi": "DECIMAL(18,8) DEFAULT NULL",
  105. "predicted_tail_revenue": "DECIMAL(20,4) DEFAULT NULL",
  106. "predicted_fission_revenue": "DECIMAL(20,4) DEFAULT NULL",
  107. },
  108. "roi_action_item": {
  109. "approval_status": "VARCHAR(32) NOT NULL DEFAULT 'PENDING'",
  110. "approval_source": "VARCHAR(32) DEFAULT NULL",
  111. "approved_at": "DATETIME DEFAULT NULL",
  112. "rejected_at": "DATETIME DEFAULT NULL",
  113. "sheet_row_number": "INT DEFAULT NULL",
  114. "result_notified_at": "DATETIME DEFAULT NULL",
  115. "notification_error": "TEXT DEFAULT NULL",
  116. },
  117. }
  118. for table_name, columns in roi_migrations.items():
  119. cursor.execute(
  120. """
  121. SELECT COLUMN_NAME
  122. FROM information_schema.COLUMNS
  123. WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s
  124. """,
  125. (os.environ["DB_NAME"], table_name),
  126. )
  127. table_columns = {
  128. row["COLUMN_NAME"] for row in cursor.fetchall()
  129. }
  130. for column, definition in columns.items():
  131. if column not in table_columns:
  132. cursor.execute(
  133. f"ALTER TABLE {table_name} "
  134. f"ADD COLUMN {column} {definition}"
  135. )
  136. cursor.execute(
  137. """
  138. SELECT INDEX_NAME
  139. FROM information_schema.STATISTICS
  140. WHERE TABLE_SCHEMA=%s
  141. AND TABLE_NAME='roi_action_item'
  142. AND INDEX_NAME='idx_roi_action_approval'
  143. """,
  144. (os.environ["DB_NAME"],),
  145. )
  146. if not cursor.fetchone():
  147. cursor.execute(
  148. """
  149. CREATE INDEX idx_roi_action_approval
  150. ON roi_action_item (run_id, approval_status)
  151. """
  152. )
  153. finally:
  154. connection.close()
  155. @contextmanager
  156. def advisory_lock(lock_name: str) -> Iterator[bool]:
  157. connection = connect()
  158. acquired = False
  159. try:
  160. with connection.cursor() as cursor:
  161. cursor.execute("SELECT GET_LOCK(%s, 0) AS acquired", (lock_name,))
  162. acquired = bool((cursor.fetchone() or {}).get("acquired"))
  163. yield acquired
  164. finally:
  165. if acquired:
  166. try:
  167. with connection.cursor() as cursor:
  168. cursor.execute("SELECT RELEASE_LOCK(%s)", (lock_name,))
  169. except Exception:
  170. pass
  171. connection.close()
  172. def load_enabled_accounts() -> list[dict[str, Any]]:
  173. connection = connect()
  174. try:
  175. with connection.cursor() as cursor:
  176. cursor.execute(
  177. """
  178. SELECT c.account_id, c.audience_name, c.bid_scene
  179. FROM ad_creation_account_config c
  180. JOIN account_whitelist w ON w.account_id = c.account_id
  181. WHERE c.enabled = TRUE
  182. AND w.enabled = TRUE
  183. ORDER BY c.account_id
  184. """
  185. )
  186. return list(cursor.fetchall())
  187. finally:
  188. connection.close()
  189. def load_realtime_accounts() -> list[dict[str, Any]]:
  190. """All historical automation accounts still enabled by the whitelist."""
  191. connection = connect()
  192. try:
  193. with connection.cursor() as cursor:
  194. cursor.execute(
  195. """
  196. SELECT c.account_id, c.audience_name, c.bid_scene
  197. FROM ad_creation_account_config c
  198. JOIN account_whitelist w ON w.account_id = c.account_id
  199. WHERE w.enabled = TRUE
  200. ORDER BY c.account_id
  201. """
  202. )
  203. return list(cursor.fetchall())
  204. finally:
  205. connection.close()
  206. def load_managed_accounts() -> list[dict[str, Any]]:
  207. """Historical automation accounts still allowed by the account whitelist."""
  208. connection = connect()
  209. try:
  210. with connection.cursor() as cursor:
  211. cursor.execute(
  212. """
  213. SELECT c.account_id, c.audience_name, c.bid_scene, c.enabled
  214. FROM ad_creation_account_config c
  215. JOIN account_whitelist w ON w.account_id = c.account_id
  216. WHERE w.enabled = TRUE
  217. ORDER BY c.account_id
  218. """
  219. )
  220. return list(cursor.fetchall())
  221. finally:
  222. connection.close()
  223. def load_daily_state(control_date: date) -> dict[str, Any]:
  224. connection = connect()
  225. try:
  226. with connection.cursor() as cursor:
  227. cursor.execute(
  228. "SELECT * FROM realtime_control_daily_state WHERE control_date=%s",
  229. (control_date,),
  230. )
  231. return cursor.fetchone() or {}
  232. finally:
  233. connection.close()
  234. def save_daily_state(control_date: date, **values: Any) -> None:
  235. allowed = {
  236. "morning_recovery_done",
  237. "cutoff_done",
  238. "last_observed_partition",
  239. "last_observed_cpm",
  240. "last_decision",
  241. "last_inventory_refresh_at",
  242. "last_evaluated_at",
  243. }
  244. unknown = set(values) - allowed
  245. if unknown:
  246. raise ValueError(f"Unsupported daily-state fields: {sorted(unknown)}")
  247. columns = ["control_date", *values]
  248. params = [control_date, *values.values()]
  249. updates = ", ".join(f"{column}=VALUES({column})" for column in values)
  250. placeholders = ", ".join(["%s"] * len(columns))
  251. sql = (
  252. f"INSERT INTO realtime_control_daily_state ({', '.join(columns)}) "
  253. f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}"
  254. )
  255. connection = connect()
  256. try:
  257. with connection.cursor() as cursor:
  258. cursor.execute(sql, params)
  259. finally:
  260. connection.close()
  261. def load_ad_states(account_id: int) -> dict[int, dict[str, Any]]:
  262. connection = connect()
  263. try:
  264. with connection.cursor() as cursor:
  265. cursor.execute(
  266. "SELECT * FROM realtime_control_ad_state WHERE account_id=%s",
  267. (account_id,),
  268. )
  269. return {int(row["adgroup_id"]): row for row in cursor.fetchall()}
  270. finally:
  271. connection.close()
  272. def upsert_ad_state(
  273. *,
  274. account_id: int,
  275. adgroup_id: int,
  276. adgroup_name: str,
  277. bid_field: str,
  278. base_bid_fen: int,
  279. boosted_date: date | None,
  280. paused_by_strategy: bool,
  281. pause_reason: str | None,
  282. last_action: str,
  283. action_at: datetime,
  284. ) -> None:
  285. connection = connect()
  286. try:
  287. with connection.cursor() as cursor:
  288. cursor.execute(
  289. """
  290. INSERT INTO realtime_control_ad_state
  291. (account_id, adgroup_id, adgroup_name, bid_field, base_bid_fen,
  292. initial_base_bid_fen,
  293. boosted_date, paused_by_strategy, pause_reason, last_action,
  294. last_action_at, last_seen_at)
  295. VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
  296. ON DUPLICATE KEY UPDATE
  297. adgroup_name=VALUES(adgroup_name),
  298. bid_field=VALUES(bid_field),
  299. initial_base_bid_fen=COALESCE(
  300. initial_base_bid_fen,
  301. VALUES(initial_base_bid_fen)
  302. ),
  303. boosted_date=VALUES(boosted_date),
  304. paused_by_strategy=VALUES(paused_by_strategy),
  305. pause_reason=VALUES(pause_reason),
  306. last_action=VALUES(last_action),
  307. last_action_at=VALUES(last_action_at),
  308. last_seen_at=VALUES(last_seen_at)
  309. """,
  310. (
  311. account_id,
  312. adgroup_id,
  313. adgroup_name,
  314. bid_field,
  315. base_bid_fen,
  316. base_bid_fen,
  317. boosted_date,
  318. paused_by_strategy,
  319. pause_reason,
  320. last_action,
  321. action_at,
  322. action_at,
  323. ),
  324. )
  325. finally:
  326. connection.close()
  327. def sync_roi_base_bid(
  328. *,
  329. account_id: int,
  330. adgroup_id: int,
  331. adgroup_name: str,
  332. bid_field: str,
  333. initial_base_bid_fen: int,
  334. new_base_bid_fen: int,
  335. boosted_date: date | None,
  336. action_at: datetime,
  337. ) -> None:
  338. """Persist a permanent ROI base bid without losing intraday boost state."""
  339. connection = connect()
  340. try:
  341. with connection.cursor() as cursor:
  342. cursor.execute(
  343. """
  344. INSERT INTO realtime_control_ad_state
  345. (account_id, adgroup_id, adgroup_name, bid_field,
  346. base_bid_fen, initial_base_bid_fen, boosted_date,
  347. paused_by_strategy, pause_reason, last_action,
  348. last_action_at, last_seen_at, last_roi_scaled_at)
  349. VALUES (%s,%s,%s,%s,%s,%s,%s,FALSE,NULL,'ROI_SCALE',%s,%s,%s)
  350. ON DUPLICATE KEY UPDATE
  351. adgroup_name=VALUES(adgroup_name),
  352. bid_field=VALUES(bid_field),
  353. base_bid_fen=VALUES(base_bid_fen),
  354. initial_base_bid_fen=COALESCE(
  355. initial_base_bid_fen,
  356. VALUES(initial_base_bid_fen)
  357. ),
  358. boosted_date=VALUES(boosted_date),
  359. last_action='ROI_SCALE',
  360. last_action_at=VALUES(last_action_at),
  361. last_seen_at=VALUES(last_seen_at),
  362. last_roi_scaled_at=VALUES(last_roi_scaled_at)
  363. """,
  364. (
  365. account_id,
  366. adgroup_id,
  367. adgroup_name,
  368. bid_field,
  369. new_base_bid_fen,
  370. initial_base_bid_fen,
  371. boosted_date,
  372. action_at,
  373. action_at,
  374. action_at,
  375. ),
  376. )
  377. finally:
  378. connection.close()
  379. def insert_action_log(record: dict[str, Any]) -> None:
  380. columns = [
  381. "run_id",
  382. "control_date",
  383. "observed_partition",
  384. "observed_cpm",
  385. "decision",
  386. "account_id",
  387. "adgroup_id",
  388. "adgroup_name",
  389. "bid_field",
  390. "base_bid_fen",
  391. "before_bid_fen",
  392. "target_bid_fen",
  393. "before_status",
  394. "target_status",
  395. "apply_mode",
  396. "execution_status",
  397. "error_message",
  398. ]
  399. connection = connect()
  400. try:
  401. with connection.cursor() as cursor:
  402. cursor.execute(
  403. f"INSERT INTO realtime_control_action_log ({', '.join(columns)}) "
  404. f"VALUES ({', '.join(['%s'] * len(columns))})",
  405. [record.get(column) for column in columns],
  406. )
  407. finally:
  408. connection.close()
  409. def create_operator_command(record: dict[str, Any]) -> dict[str, Any]:
  410. connection = connect()
  411. try:
  412. with connection.cursor() as cursor:
  413. try:
  414. cursor.execute(
  415. """
  416. INSERT INTO operator_command
  417. (command_id, source_message_id, chat_id, sender_open_id,
  418. sender_name, action, scope_type, target_account_ids,
  419. status, preview_account_count, preview_ad_count, expires_at)
  420. VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
  421. """,
  422. (
  423. record["command_id"],
  424. record["source_message_id"],
  425. record["chat_id"],
  426. record["sender_open_id"],
  427. record.get("sender_name"),
  428. record["action"],
  429. record["scope_type"],
  430. json.dumps(record["target_account_ids"]),
  431. record["status"],
  432. record.get("preview_account_count", 0),
  433. record.get("preview_ad_count", 0),
  434. record.get("expires_at"),
  435. ),
  436. )
  437. except pymysql.err.IntegrityError:
  438. cursor.execute(
  439. "SELECT * FROM operator_command WHERE source_message_id=%s",
  440. (record["source_message_id"],),
  441. )
  442. existing = cursor.fetchone()
  443. if existing:
  444. existing["target_account_ids"] = json.loads(
  445. existing.get("target_account_ids") or "[]"
  446. )
  447. return existing
  448. raise
  449. return load_operator_command(record["command_id"]) or {}
  450. finally:
  451. connection.close()
  452. def load_operator_command(command_id: str) -> dict[str, Any] | None:
  453. connection = connect()
  454. try:
  455. with connection.cursor() as cursor:
  456. cursor.execute(
  457. "SELECT * FROM operator_command WHERE command_id=%s",
  458. (command_id,),
  459. )
  460. row = cursor.fetchone()
  461. if row:
  462. row["target_account_ids"] = json.loads(
  463. row.get("target_account_ids") or "[]"
  464. )
  465. return row
  466. finally:
  467. connection.close()
  468. def update_operator_command(command_id: str, status: str, **values: Any) -> None:
  469. allowed = {"confirmed_at", "executed_at", "error_message"}
  470. unknown = set(values) - allowed
  471. if unknown:
  472. raise ValueError(f"Unsupported operator-command fields: {sorted(unknown)}")
  473. assignments = ["status=%s"]
  474. params: list[Any] = [status]
  475. for column, value in values.items():
  476. assignments.append(f"{column}=%s")
  477. params.append(value)
  478. params.append(command_id)
  479. connection = connect()
  480. try:
  481. with connection.cursor() as cursor:
  482. cursor.execute(
  483. f"UPDATE operator_command SET {', '.join(assignments)} "
  484. "WHERE command_id=%s",
  485. params,
  486. )
  487. finally:
  488. connection.close()
  489. def transition_operator_command(
  490. command_id: str,
  491. *,
  492. expected_statuses: set[str],
  493. target_status: str,
  494. **values: Any,
  495. ) -> bool:
  496. allowed = {"confirmed_at", "executed_at", "error_message"}
  497. unknown = set(values) - allowed
  498. if unknown:
  499. raise ValueError(f"Unsupported operator-command fields: {sorted(unknown)}")
  500. if not expected_statuses:
  501. raise ValueError("expected_statuses must not be empty")
  502. assignments = ["status=%s"]
  503. params: list[Any] = [target_status]
  504. for column, value in values.items():
  505. assignments.append(f"{column}=%s")
  506. params.append(value)
  507. placeholders = ", ".join(["%s"] * len(expected_statuses))
  508. params.extend([command_id, *sorted(expected_statuses)])
  509. connection = connect()
  510. try:
  511. with connection.cursor() as cursor:
  512. affected = cursor.execute(
  513. f"""
  514. UPDATE operator_command
  515. SET {', '.join(assignments)}
  516. WHERE command_id=%s
  517. AND status IN ({placeholders})
  518. """,
  519. params,
  520. )
  521. return affected == 1
  522. finally:
  523. connection.close()
  524. def insert_operator_command_item(record: dict[str, Any]) -> None:
  525. columns = [
  526. "command_id",
  527. "account_id",
  528. "audience_name",
  529. "adgroup_id",
  530. "adgroup_name",
  531. "before_status",
  532. "target_status",
  533. "readback_status",
  534. "execution_status",
  535. "error_message",
  536. ]
  537. connection = connect()
  538. try:
  539. with connection.cursor() as cursor:
  540. cursor.execute(
  541. f"INSERT INTO operator_command_item ({', '.join(columns)}) "
  542. f"VALUES ({', '.join(['%s'] * len(columns))})",
  543. [record.get(column) for column in columns],
  544. )
  545. finally:
  546. connection.close()
  547. def set_operator_pause(
  548. *,
  549. account_id: int,
  550. adgroup_id: int,
  551. mode: str,
  552. resume_at: datetime | None,
  553. command_id: str,
  554. paused_from_status: str,
  555. paused_at: datetime,
  556. ) -> None:
  557. connection = connect()
  558. try:
  559. with connection.cursor() as cursor:
  560. cursor.execute(
  561. """
  562. UPDATE realtime_control_ad_state
  563. SET operator_pause_mode=%s,
  564. operator_resume_at=%s,
  565. operator_command_id=%s,
  566. operator_paused_from_status=%s,
  567. operator_paused_at=%s,
  568. last_action='OPERATOR_PAUSE',
  569. last_action_at=%s,
  570. last_seen_at=%s
  571. WHERE account_id=%s AND adgroup_id=%s
  572. """,
  573. (
  574. mode,
  575. resume_at,
  576. command_id,
  577. paused_from_status,
  578. paused_at,
  579. paused_at,
  580. paused_at,
  581. account_id,
  582. adgroup_id,
  583. ),
  584. )
  585. finally:
  586. connection.close()
  587. def clear_operator_pause(
  588. account_id: int,
  589. adgroup_id: int,
  590. *,
  591. action: str,
  592. action_at: datetime,
  593. ) -> None:
  594. connection = connect()
  595. try:
  596. with connection.cursor() as cursor:
  597. cursor.execute(
  598. """
  599. UPDATE realtime_control_ad_state
  600. SET operator_pause_mode=NULL,
  601. operator_resume_at=NULL,
  602. operator_command_id=NULL,
  603. operator_paused_from_status=NULL,
  604. operator_paused_at=NULL,
  605. last_action=%s,
  606. last_action_at=%s,
  607. last_seen_at=%s
  608. WHERE account_id=%s AND adgroup_id=%s
  609. """,
  610. (action, action_at, action_at, account_id, adgroup_id),
  611. )
  612. finally:
  613. connection.close()
  614. def load_operator_pauses(
  615. account_ids: list[int] | None = None,
  616. ) -> list[dict[str, Any]]:
  617. params: list[Any] = []
  618. where = "WHERE operator_pause_mode IS NOT NULL"
  619. if account_ids is not None:
  620. if not account_ids:
  621. return []
  622. where += f" AND account_id IN ({', '.join(['%s'] * len(account_ids))})"
  623. params.extend(account_ids)
  624. connection = connect()
  625. try:
  626. with connection.cursor() as cursor:
  627. cursor.execute(
  628. f"""
  629. SELECT *
  630. FROM realtime_control_ad_state
  631. {where}
  632. ORDER BY account_id, adgroup_id
  633. """,
  634. params,
  635. )
  636. return list(cursor.fetchall())
  637. finally:
  638. connection.close()