storage.py 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312
  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. "bid_hold": "BOOLEAN NOT NULL DEFAULT FALSE",
  55. "bid_hold_reason": "VARCHAR(255) DEFAULT NULL",
  56. "operator_pause_mode": "VARCHAR(32) DEFAULT NULL",
  57. "operator_resume_at": "DATETIME DEFAULT NULL",
  58. "operator_command_id": "VARCHAR(64) DEFAULT NULL",
  59. "operator_paused_from_status": "VARCHAR(50) DEFAULT NULL",
  60. "operator_paused_at": "DATETIME DEFAULT NULL",
  61. }
  62. for column, definition in migrations.items():
  63. if column not in existing_columns:
  64. cursor.execute(
  65. f"ALTER TABLE realtime_control_ad_state "
  66. f"ADD COLUMN {column} {definition}"
  67. )
  68. cursor.execute(
  69. """
  70. UPDATE realtime_control_ad_state
  71. SET initial_base_bid_fen=base_bid_fen
  72. WHERE initial_base_bid_fen IS NULL
  73. """
  74. )
  75. cursor.execute(
  76. """
  77. SELECT INDEX_NAME
  78. FROM information_schema.STATISTICS
  79. WHERE TABLE_SCHEMA=%s
  80. AND TABLE_NAME='realtime_control_ad_state'
  81. AND INDEX_NAME='idx_operator_pause'
  82. """,
  83. (os.environ["DB_NAME"],),
  84. )
  85. if not cursor.fetchone():
  86. cursor.execute(
  87. """
  88. CREATE INDEX idx_operator_pause
  89. ON realtime_control_ad_state
  90. (operator_pause_mode, operator_resume_at)
  91. """
  92. )
  93. operator_migrations = {
  94. "operator_command": {
  95. "raw_text": "MEDIUMTEXT DEFAULT NULL",
  96. "parse_source": "VARCHAR(32) NOT NULL DEFAULT 'deterministic'",
  97. "intent_json": "MEDIUMTEXT DEFAULT NULL",
  98. "preview_cost_fen": "BIGINT NOT NULL DEFAULT 0",
  99. "preview_impressions": "BIGINT NOT NULL DEFAULT 0",
  100. "preview_clicks": "BIGINT NOT NULL DEFAULT 0",
  101. "preview_conversions": "BIGINT NOT NULL DEFAULT 0",
  102. "previewed_at": "DATETIME DEFAULT NULL",
  103. "resume_at": "DATETIME DEFAULT NULL",
  104. },
  105. "operator_command_item": {
  106. "preview_cost_fen": "BIGINT NOT NULL DEFAULT 0",
  107. "preview_impressions": "BIGINT NOT NULL DEFAULT 0",
  108. "preview_clicks": "BIGINT NOT NULL DEFAULT 0",
  109. "preview_conversions": "BIGINT NOT NULL DEFAULT 0",
  110. "previewed_at": "DATETIME DEFAULT NULL",
  111. "updated_at": (
  112. "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP "
  113. "ON UPDATE CURRENT_TIMESTAMP"
  114. ),
  115. },
  116. }
  117. for table_name, columns in operator_migrations.items():
  118. cursor.execute(
  119. """
  120. SELECT COLUMN_NAME
  121. FROM information_schema.COLUMNS
  122. WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s
  123. """,
  124. (os.environ["DB_NAME"], table_name),
  125. )
  126. table_columns = {row["COLUMN_NAME"] for row in cursor.fetchall()}
  127. for column, definition in columns.items():
  128. if column not in table_columns:
  129. cursor.execute(
  130. f"ALTER TABLE {table_name} ADD COLUMN {column} {definition}"
  131. )
  132. schema_migrations = {
  133. "roi_metric_run": {
  134. "fission_parameter_version": "VARCHAR(64) DEFAULT NULL",
  135. "fission_cohort_date": "DATE DEFAULT NULL",
  136. },
  137. "roi_entity_snapshot": {
  138. "conversion_goal": "VARCHAR(255) DEFAULT NULL",
  139. "fission_parameter_version": "VARCHAR(64) DEFAULT NULL",
  140. "fission_cohort_date": "VARCHAR(8) DEFAULT NULL",
  141. "fission_multiplier_vs_t0": "DECIMAL(18,8) DEFAULT NULL",
  142. "fission_multiplier_vs_first": "DECIMAL(18,8) DEFAULT NULL",
  143. "fission_match_level": "VARCHAR(64) DEFAULT NULL",
  144. "fission_source": "VARCHAR(512) DEFAULT NULL",
  145. "actual_total_revenue": "DECIMAL(20,4) DEFAULT NULL",
  146. "actual_roi": "DECIMAL(18,8) DEFAULT NULL",
  147. "predicted_tail_revenue": "DECIMAL(20,4) DEFAULT NULL",
  148. "predicted_fission_revenue": "DECIMAL(20,4) DEFAULT NULL",
  149. },
  150. "roi_fission_parameter_value": {
  151. "multiplier_vs_first": "DOUBLE DEFAULT NULL",
  152. },
  153. "roi_action_item": {
  154. "approval_status": "VARCHAR(32) NOT NULL DEFAULT 'PENDING'",
  155. "approval_source": "VARCHAR(32) DEFAULT NULL",
  156. "approved_at": "DATETIME DEFAULT NULL",
  157. "rejected_at": "DATETIME DEFAULT NULL",
  158. "sheet_row_number": "INT DEFAULT NULL",
  159. "result_notified_at": "DATETIME DEFAULT NULL",
  160. "notification_error": "TEXT DEFAULT NULL",
  161. },
  162. "roi_agency_delivery": {
  163. "creative_rows": "INT NOT NULL DEFAULT 0",
  164. "ad_rows": "INT NOT NULL DEFAULT 0",
  165. },
  166. "revenue_forecast_result": {
  167. "cost_reserve_date": "DATE DEFAULT NULL",
  168. "channel_costs_json": "TEXT DEFAULT NULL",
  169. "non_miniapp_reserved_cost": (
  170. "DECIMAL(20,4) NOT NULL DEFAULT 0"
  171. ),
  172. "miniapp_target_daily_cost": (
  173. "DECIMAL(20,4) NOT NULL DEFAULT 0"
  174. ),
  175. "forecast_method": (
  176. "VARCHAR(64) NOT NULL DEFAULT 'weighted_30m_speed'"
  177. ),
  178. "parameter_version": "VARCHAR(64) DEFAULT NULL",
  179. "current_cumulative_revenue": (
  180. "DECIMAL(20,4) DEFAULT NULL"
  181. ),
  182. "speed_latest_weight": "DECIMAL(12,8) DEFAULT NULL",
  183. "latest_interval_revenue": "DECIMAL(20,4) DEFAULT NULL",
  184. "previous_interval_revenue": "DECIMAL(20,4) DEFAULT NULL",
  185. "weighted_speed": "DECIMAL(20,4) DEFAULT NULL",
  186. "remaining_multiplier_p10": "DECIMAL(20,8) DEFAULT NULL",
  187. "remaining_multiplier_p50": "DECIMAL(20,8) DEFAULT NULL",
  188. "remaining_multiplier_p90": "DECIMAL(20,8) DEFAULT NULL",
  189. "parameter_sample_count": "INT DEFAULT NULL",
  190. },
  191. "revenue_forecast_observation": {
  192. "source_version": (
  193. "VARCHAR(64) NOT NULL DEFAULT "
  194. "'legacy'"
  195. ),
  196. },
  197. }
  198. for table_name, columns in schema_migrations.items():
  199. cursor.execute(
  200. """
  201. SELECT COLUMN_NAME
  202. FROM information_schema.COLUMNS
  203. WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s
  204. """,
  205. (os.environ["DB_NAME"], table_name),
  206. )
  207. table_columns = {
  208. row["COLUMN_NAME"] for row in cursor.fetchall()
  209. }
  210. for column, definition in columns.items():
  211. if column not in table_columns:
  212. cursor.execute(
  213. f"ALTER TABLE {table_name} "
  214. f"ADD COLUMN {column} {definition}"
  215. )
  216. cursor.execute(
  217. """
  218. SELECT COLUMN_NAME, IS_NULLABLE
  219. FROM information_schema.COLUMNS
  220. WHERE TABLE_SCHEMA=%s
  221. AND TABLE_NAME='revenue_forecast_result'
  222. AND COLUMN_NAME IN (
  223. 'signal_a', 'trend_growths_json', 'signal_a_weight',
  224. 'signal_b_weight', 'confidence_ratio'
  225. )
  226. """,
  227. (os.environ["DB_NAME"],),
  228. )
  229. legacy_required_columns = {
  230. row["COLUMN_NAME"]
  231. for row in cursor.fetchall()
  232. if row["IS_NULLABLE"] == "NO"
  233. }
  234. legacy_definitions = {
  235. "signal_a": "DECIMAL(20,4) DEFAULT NULL",
  236. "trend_growths_json": "TEXT DEFAULT NULL",
  237. "signal_a_weight": "DECIMAL(8,6) DEFAULT NULL",
  238. "signal_b_weight": "DECIMAL(8,6) DEFAULT NULL",
  239. "confidence_ratio": "DECIMAL(8,6) DEFAULT NULL",
  240. }
  241. for column in sorted(legacy_required_columns):
  242. cursor.execute(
  243. f"ALTER TABLE revenue_forecast_result MODIFY COLUMN "
  244. f"{column} {legacy_definitions[column]}"
  245. )
  246. cursor.execute(
  247. """
  248. SELECT COLUMN_NAME, IS_NULLABLE
  249. FROM information_schema.COLUMNS
  250. WHERE TABLE_SCHEMA=%s
  251. AND TABLE_NAME='revenue_forecast_observation'
  252. AND COLUMN_NAME IN (
  253. 'yesterday_same_time_revenue',
  254. 'yesterday_total_revenue'
  255. )
  256. """,
  257. (os.environ["DB_NAME"],),
  258. )
  259. for row in cursor.fetchall():
  260. if row["IS_NULLABLE"] == "NO":
  261. cursor.execute(
  262. f"ALTER TABLE revenue_forecast_observation "
  263. f"MODIFY COLUMN {row['COLUMN_NAME']} "
  264. f"DECIMAL(20,4) DEFAULT NULL"
  265. )
  266. cursor.execute(
  267. """
  268. UPDATE revenue_forecast_observation o
  269. JOIN (
  270. SELECT report_time, MIN(forecast_version) AS forecast_version
  271. FROM revenue_forecast_result
  272. GROUP BY report_time
  273. HAVING COUNT(DISTINCT forecast_version) = 1
  274. ) r ON r.report_time = o.report_time
  275. LEFT JOIN revenue_forecast_observation exact_observation
  276. ON exact_observation.report_time = o.report_time
  277. AND exact_observation.source_version = r.forecast_version
  278. AND exact_observation.id <> o.id
  279. SET o.source_version = r.forecast_version
  280. WHERE o.source_version IN (
  281. 'legacy', 'revenue_forecast_v4_45m'
  282. )
  283. AND exact_observation.id IS NULL
  284. """
  285. )
  286. cursor.execute(
  287. """
  288. SELECT INDEX_NAME
  289. FROM information_schema.STATISTICS
  290. WHERE TABLE_SCHEMA=%s
  291. AND TABLE_NAME='revenue_forecast_observation'
  292. AND INDEX_NAME='uk_revenue_observation_report_time'
  293. LIMIT 1
  294. """,
  295. (os.environ["DB_NAME"],),
  296. )
  297. if cursor.fetchone():
  298. cursor.execute(
  299. """
  300. ALTER TABLE revenue_forecast_observation
  301. DROP INDEX uk_revenue_observation_report_time
  302. """
  303. )
  304. cursor.execute(
  305. """
  306. SELECT INDEX_NAME
  307. FROM information_schema.STATISTICS
  308. WHERE TABLE_SCHEMA=%s
  309. AND TABLE_NAME='revenue_forecast_observation'
  310. AND INDEX_NAME='uk_revenue_observation_version_time'
  311. LIMIT 1
  312. """,
  313. (os.environ["DB_NAME"],),
  314. )
  315. if not cursor.fetchone():
  316. cursor.execute(
  317. """
  318. CREATE UNIQUE INDEX uk_revenue_observation_version_time
  319. ON revenue_forecast_observation
  320. (report_time, source_version)
  321. """
  322. )
  323. cursor.execute(
  324. """
  325. SELECT INDEX_NAME
  326. FROM information_schema.STATISTICS
  327. WHERE TABLE_SCHEMA=%s
  328. AND TABLE_NAME='roi_action_item'
  329. AND INDEX_NAME='idx_roi_action_approval'
  330. """,
  331. (os.environ["DB_NAME"],),
  332. )
  333. if not cursor.fetchone():
  334. cursor.execute(
  335. """
  336. CREATE INDEX idx_roi_action_approval
  337. ON roi_action_item (run_id, approval_status)
  338. """
  339. )
  340. finally:
  341. connection.close()
  342. @contextmanager
  343. def advisory_lock(lock_name: str) -> Iterator[bool]:
  344. connection = connect()
  345. acquired = False
  346. try:
  347. with connection.cursor() as cursor:
  348. cursor.execute("SELECT GET_LOCK(%s, 0) AS acquired", (lock_name,))
  349. acquired = bool((cursor.fetchone() or {}).get("acquired"))
  350. yield acquired
  351. finally:
  352. if acquired:
  353. try:
  354. with connection.cursor() as cursor:
  355. cursor.execute("SELECT RELEASE_LOCK(%s)", (lock_name,))
  356. except Exception:
  357. pass
  358. connection.close()
  359. def load_enabled_accounts() -> list[dict[str, Any]]:
  360. connection = connect()
  361. try:
  362. with connection.cursor() as cursor:
  363. cursor.execute(
  364. """
  365. SELECT c.account_id, c.audience_name, c.bid_scene
  366. FROM ad_creation_account_config c
  367. JOIN account_whitelist w ON w.account_id = c.account_id
  368. WHERE c.enabled = TRUE
  369. AND w.enabled = TRUE
  370. ORDER BY c.account_id
  371. """
  372. )
  373. return list(cursor.fetchall())
  374. finally:
  375. connection.close()
  376. def load_automation_spend_accounts() -> list[dict[str, Any]]:
  377. """Load historical automation accounts that remain whitelisted."""
  378. connection = connect()
  379. try:
  380. with connection.cursor() as cursor:
  381. cursor.execute(
  382. """
  383. SELECT c.account_id
  384. FROM ad_creation_account_config c
  385. JOIN account_whitelist w ON w.account_id = c.account_id
  386. WHERE w.enabled = TRUE
  387. ORDER BY c.account_id
  388. """
  389. )
  390. return list(cursor.fetchall())
  391. finally:
  392. connection.close()
  393. def load_all_spend_accounts() -> list[dict[str, Any]]:
  394. """Load every enabled account from the local account whitelist."""
  395. connection = connect()
  396. try:
  397. with connection.cursor() as cursor:
  398. cursor.execute(
  399. """
  400. SELECT w.account_id
  401. FROM account_whitelist w
  402. WHERE w.enabled = TRUE
  403. ORDER BY w.account_id
  404. """
  405. )
  406. return list(cursor.fetchall())
  407. finally:
  408. connection.close()
  409. def load_realtime_accounts() -> list[dict[str, Any]]:
  410. """Load full-control automation accounts plus explicit extra scope."""
  411. connection = connect()
  412. try:
  413. with connection.cursor() as cursor:
  414. cursor.execute(
  415. """
  416. SELECT c.account_id, c.audience_name, c.bid_scene,
  417. 'FULL' AS control_mode
  418. FROM ad_creation_account_config c
  419. JOIN account_whitelist w ON w.account_id = c.account_id
  420. WHERE w.enabled = TRUE
  421. ORDER BY c.account_id
  422. """
  423. )
  424. accounts = {
  425. int(row["account_id"]): row for row in cursor.fetchall()
  426. }
  427. cursor.execute(
  428. """
  429. SELECT account_id, audience_name, bid_scene, control_mode
  430. FROM realtime_control_account_scope
  431. WHERE enabled=TRUE
  432. ORDER BY account_id
  433. """
  434. )
  435. for row in cursor.fetchall():
  436. accounts.setdefault(int(row["account_id"]), row)
  437. return [accounts[key] for key in sorted(accounts)]
  438. finally:
  439. connection.close()
  440. def upsert_realtime_account_scope(
  441. *,
  442. account_id: int,
  443. control_mode: str,
  444. audience_name: str,
  445. bid_scene: str | None,
  446. source: str,
  447. note: str,
  448. ) -> None:
  449. if control_mode not in {"FULL", "PAUSE_ONLY"}:
  450. raise ValueError(f"Unsupported real-time control mode: {control_mode}")
  451. connection = connect()
  452. try:
  453. with connection.cursor() as cursor:
  454. cursor.execute(
  455. """
  456. INSERT INTO realtime_control_account_scope
  457. (account_id, control_mode, audience_name, bid_scene,
  458. enabled, source, note)
  459. VALUES (%s,%s,%s,%s,TRUE,%s,%s)
  460. ON DUPLICATE KEY UPDATE
  461. control_mode=VALUES(control_mode),
  462. audience_name=VALUES(audience_name),
  463. bid_scene=VALUES(bid_scene),
  464. enabled=TRUE,
  465. source=VALUES(source),
  466. note=VALUES(note)
  467. """,
  468. (
  469. account_id,
  470. control_mode,
  471. audience_name,
  472. bid_scene,
  473. source,
  474. note,
  475. ),
  476. )
  477. finally:
  478. connection.close()
  479. def load_managed_accounts() -> list[dict[str, Any]]:
  480. """Historical automation accounts still allowed by the account whitelist."""
  481. connection = connect()
  482. try:
  483. with connection.cursor() as cursor:
  484. cursor.execute(
  485. """
  486. SELECT c.account_id, c.audience_name, c.bid_scene, c.enabled
  487. FROM ad_creation_account_config c
  488. JOIN account_whitelist w ON w.account_id = c.account_id
  489. WHERE w.enabled = TRUE
  490. ORDER BY c.account_id
  491. """
  492. )
  493. return list(cursor.fetchall())
  494. finally:
  495. connection.close()
  496. def load_daily_state(control_date: date) -> dict[str, Any]:
  497. connection = connect()
  498. try:
  499. with connection.cursor() as cursor:
  500. cursor.execute(
  501. "SELECT * FROM realtime_control_daily_state WHERE control_date=%s",
  502. (control_date,),
  503. )
  504. return cursor.fetchone() or {}
  505. finally:
  506. connection.close()
  507. def save_daily_state(control_date: date, **values: Any) -> None:
  508. allowed = {
  509. "morning_recovery_done",
  510. "cutoff_done",
  511. "last_observed_partition",
  512. "last_observed_cpm",
  513. "last_decision",
  514. "last_inventory_refresh_at",
  515. "last_evaluated_at",
  516. }
  517. unknown = set(values) - allowed
  518. if unknown:
  519. raise ValueError(f"Unsupported daily-state fields: {sorted(unknown)}")
  520. columns = ["control_date", *values]
  521. params = [control_date, *values.values()]
  522. updates = ", ".join(f"{column}=VALUES({column})" for column in values)
  523. placeholders = ", ".join(["%s"] * len(columns))
  524. sql = (
  525. f"INSERT INTO realtime_control_daily_state ({', '.join(columns)}) "
  526. f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}"
  527. )
  528. connection = connect()
  529. try:
  530. with connection.cursor() as cursor:
  531. cursor.execute(sql, params)
  532. finally:
  533. connection.close()
  534. def load_ad_states(account_id: int) -> dict[int, dict[str, Any]]:
  535. connection = connect()
  536. try:
  537. with connection.cursor() as cursor:
  538. cursor.execute(
  539. "SELECT * FROM realtime_control_ad_state WHERE account_id=%s",
  540. (account_id,),
  541. )
  542. return {int(row["adgroup_id"]): row for row in cursor.fetchall()}
  543. finally:
  544. connection.close()
  545. def upsert_ad_state(
  546. *,
  547. account_id: int,
  548. adgroup_id: int,
  549. adgroup_name: str,
  550. bid_field: str,
  551. base_bid_fen: int,
  552. boosted_date: date | None,
  553. paused_by_strategy: bool,
  554. pause_reason: str | None,
  555. last_action: str,
  556. action_at: datetime,
  557. ) -> None:
  558. connection = connect()
  559. try:
  560. with connection.cursor() as cursor:
  561. cursor.execute(
  562. """
  563. INSERT INTO realtime_control_ad_state
  564. (account_id, adgroup_id, adgroup_name, bid_field, base_bid_fen,
  565. initial_base_bid_fen,
  566. boosted_date, paused_by_strategy, pause_reason, last_action,
  567. last_action_at, last_seen_at)
  568. VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
  569. ON DUPLICATE KEY UPDATE
  570. adgroup_name=VALUES(adgroup_name),
  571. bid_field=VALUES(bid_field),
  572. initial_base_bid_fen=COALESCE(
  573. initial_base_bid_fen,
  574. VALUES(initial_base_bid_fen)
  575. ),
  576. boosted_date=VALUES(boosted_date),
  577. paused_by_strategy=VALUES(paused_by_strategy),
  578. pause_reason=VALUES(pause_reason),
  579. last_action=VALUES(last_action),
  580. last_action_at=VALUES(last_action_at),
  581. last_seen_at=VALUES(last_seen_at)
  582. """,
  583. (
  584. account_id,
  585. adgroup_id,
  586. adgroup_name,
  587. bid_field,
  588. base_bid_fen,
  589. base_bid_fen,
  590. boosted_date,
  591. paused_by_strategy,
  592. pause_reason,
  593. last_action,
  594. action_at,
  595. action_at,
  596. ),
  597. )
  598. finally:
  599. connection.close()
  600. def sync_roi_base_bid(
  601. *,
  602. account_id: int,
  603. adgroup_id: int,
  604. adgroup_name: str,
  605. bid_field: str,
  606. initial_base_bid_fen: int,
  607. new_base_bid_fen: int,
  608. boosted_date: date | None,
  609. action_at: datetime,
  610. ) -> None:
  611. """Persist a permanent ROI base bid without losing intraday boost state."""
  612. connection = connect()
  613. try:
  614. with connection.cursor() as cursor:
  615. cursor.execute(
  616. """
  617. INSERT INTO realtime_control_ad_state
  618. (account_id, adgroup_id, adgroup_name, bid_field,
  619. base_bid_fen, initial_base_bid_fen, boosted_date,
  620. paused_by_strategy, pause_reason, last_action,
  621. last_action_at, last_seen_at, last_roi_scaled_at)
  622. VALUES (%s,%s,%s,%s,%s,%s,%s,FALSE,NULL,'ROI_SCALE',%s,%s,%s)
  623. ON DUPLICATE KEY UPDATE
  624. adgroup_name=VALUES(adgroup_name),
  625. bid_field=VALUES(bid_field),
  626. base_bid_fen=VALUES(base_bid_fen),
  627. initial_base_bid_fen=COALESCE(
  628. initial_base_bid_fen,
  629. VALUES(initial_base_bid_fen)
  630. ),
  631. boosted_date=VALUES(boosted_date),
  632. last_action='ROI_SCALE',
  633. last_action_at=VALUES(last_action_at),
  634. last_seen_at=VALUES(last_seen_at),
  635. last_roi_scaled_at=VALUES(last_roi_scaled_at)
  636. """,
  637. (
  638. account_id,
  639. adgroup_id,
  640. adgroup_name,
  641. bid_field,
  642. new_base_bid_fen,
  643. initial_base_bid_fen,
  644. boosted_date,
  645. action_at,
  646. action_at,
  647. action_at,
  648. ),
  649. )
  650. finally:
  651. connection.close()
  652. def set_bid_experiment_hold(
  653. *,
  654. account_id: int,
  655. adgroup_id: int,
  656. adgroup_name: str,
  657. bid_field: str,
  658. base_bid_fen: int,
  659. action_at: datetime,
  660. reason: str,
  661. ) -> None:
  662. """Persist an experiment base bid and prevent real-time bid overrides."""
  663. connection = connect()
  664. try:
  665. with connection.cursor() as cursor:
  666. cursor.execute(
  667. """
  668. INSERT INTO realtime_control_ad_state
  669. (account_id, adgroup_id, adgroup_name, bid_field,
  670. base_bid_fen, initial_base_bid_fen, boosted_date,
  671. bid_hold, bid_hold_reason, paused_by_strategy,
  672. last_action, last_action_at, last_seen_at)
  673. VALUES (%s,%s,%s,%s,%s,%s,%s,TRUE,%s,FALSE,
  674. 'BID_EXPERIMENT_HOLD',%s,%s)
  675. ON DUPLICATE KEY UPDATE
  676. adgroup_name=VALUES(adgroup_name),
  677. bid_field=VALUES(bid_field),
  678. base_bid_fen=VALUES(base_bid_fen),
  679. initial_base_bid_fen=COALESCE(
  680. initial_base_bid_fen,
  681. VALUES(initial_base_bid_fen)
  682. ),
  683. boosted_date=VALUES(boosted_date),
  684. bid_hold=TRUE,
  685. bid_hold_reason=VALUES(bid_hold_reason),
  686. last_action='BID_EXPERIMENT_HOLD',
  687. last_action_at=VALUES(last_action_at),
  688. last_seen_at=VALUES(last_seen_at)
  689. """,
  690. (
  691. account_id,
  692. adgroup_id,
  693. adgroup_name,
  694. bid_field,
  695. base_bid_fen,
  696. base_bid_fen,
  697. action_at.date(),
  698. reason,
  699. action_at,
  700. action_at,
  701. ),
  702. )
  703. finally:
  704. connection.close()
  705. def clear_bid_experiment_hold(
  706. account_id: int,
  707. adgroup_id: int,
  708. *,
  709. action_at: datetime,
  710. ) -> None:
  711. connection = connect()
  712. try:
  713. with connection.cursor() as cursor:
  714. cursor.execute(
  715. """
  716. UPDATE realtime_control_ad_state
  717. SET bid_hold=FALSE,
  718. bid_hold_reason=NULL,
  719. last_action='BID_EXPERIMENT_RELEASE',
  720. last_action_at=%s,
  721. last_seen_at=%s
  722. WHERE account_id=%s AND adgroup_id=%s
  723. """,
  724. (action_at, action_at, account_id, adgroup_id),
  725. )
  726. if cursor.rowcount != 1:
  727. raise ValueError(
  728. "未找到实验出价状态: "
  729. f"account={account_id} adgroup={adgroup_id}"
  730. )
  731. finally:
  732. connection.close()
  733. def insert_action_log(record: dict[str, Any]) -> None:
  734. columns = [
  735. "run_id",
  736. "control_date",
  737. "observed_partition",
  738. "observed_cpm",
  739. "decision",
  740. "account_id",
  741. "adgroup_id",
  742. "adgroup_name",
  743. "bid_field",
  744. "base_bid_fen",
  745. "before_bid_fen",
  746. "target_bid_fen",
  747. "before_status",
  748. "target_status",
  749. "apply_mode",
  750. "execution_status",
  751. "error_message",
  752. ]
  753. connection = connect()
  754. try:
  755. with connection.cursor() as cursor:
  756. cursor.execute(
  757. f"INSERT INTO realtime_control_action_log ({', '.join(columns)}) "
  758. f"VALUES ({', '.join(['%s'] * len(columns))})",
  759. [record.get(column) for column in columns],
  760. )
  761. finally:
  762. connection.close()
  763. def create_operator_command(record: dict[str, Any]) -> dict[str, Any]:
  764. connection = connect()
  765. try:
  766. with connection.cursor() as cursor:
  767. try:
  768. cursor.execute(
  769. """
  770. INSERT INTO operator_command
  771. (command_id, source_message_id, chat_id, sender_open_id,
  772. sender_name, action, scope_type, target_account_ids,
  773. status, preview_account_count, preview_ad_count, expires_at)
  774. VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
  775. """,
  776. (
  777. record["command_id"],
  778. record["source_message_id"],
  779. record["chat_id"],
  780. record["sender_open_id"],
  781. record.get("sender_name"),
  782. record["action"],
  783. record["scope_type"],
  784. json.dumps(record["target_account_ids"]),
  785. record["status"],
  786. record.get("preview_account_count", 0),
  787. record.get("preview_ad_count", 0),
  788. record.get("expires_at"),
  789. ),
  790. )
  791. except pymysql.err.IntegrityError:
  792. cursor.execute(
  793. "SELECT * FROM operator_command WHERE source_message_id=%s",
  794. (record["source_message_id"],),
  795. )
  796. existing = cursor.fetchone()
  797. if existing:
  798. existing["target_account_ids"] = json.loads(
  799. existing.get("target_account_ids") or "[]"
  800. )
  801. return existing
  802. raise
  803. return load_operator_command(record["command_id"]) or {}
  804. finally:
  805. connection.close()
  806. def create_operator_command_with_items(
  807. record: dict[str, Any],
  808. items: list[dict[str, Any]],
  809. ) -> dict[str, Any]:
  810. """Atomically persist an immutable command preview and its ad snapshots."""
  811. connection = connect()
  812. try:
  813. connection.begin()
  814. with connection.cursor() as cursor:
  815. try:
  816. cursor.execute(
  817. """
  818. INSERT INTO operator_command
  819. (command_id, source_message_id, chat_id, sender_open_id,
  820. sender_name, raw_text, parse_source, intent_json,
  821. action, scope_type, target_account_ids, status,
  822. preview_account_count, preview_ad_count,
  823. preview_cost_fen, preview_impressions, preview_clicks,
  824. preview_conversions, previewed_at, resume_at, expires_at)
  825. VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
  826. """,
  827. (
  828. record["command_id"], record["source_message_id"],
  829. record["chat_id"], record["sender_open_id"],
  830. record.get("sender_name"), record.get("raw_text"),
  831. record.get("parse_source", "deterministic"),
  832. json.dumps(record.get("intent") or {}, ensure_ascii=False),
  833. record["action"], record["scope_type"],
  834. json.dumps(record["target_account_ids"]), record["status"],
  835. record.get("preview_account_count", 0),
  836. record.get("preview_ad_count", 0),
  837. record.get("preview_cost_fen", 0),
  838. record.get("preview_impressions", 0),
  839. record.get("preview_clicks", 0),
  840. record.get("preview_conversions", 0),
  841. record.get("previewed_at"), record.get("resume_at"),
  842. record.get("expires_at"),
  843. ),
  844. )
  845. except pymysql.err.IntegrityError:
  846. connection.rollback()
  847. existing = load_operator_command_by_source(record["source_message_id"])
  848. if existing:
  849. return existing
  850. raise
  851. columns = [
  852. "command_id", "account_id", "audience_name", "adgroup_id",
  853. "adgroup_name", "before_status", "target_status",
  854. "preview_cost_fen", "preview_impressions", "preview_clicks",
  855. "preview_conversions", "previewed_at", "execution_status",
  856. ]
  857. for item in items:
  858. cursor.execute(
  859. f"INSERT INTO operator_command_item ({', '.join(columns)}) "
  860. f"VALUES ({', '.join(['%s'] * len(columns))})",
  861. [item.get(column) for column in columns],
  862. )
  863. connection.commit()
  864. return load_operator_command(record["command_id"]) or {}
  865. except Exception:
  866. connection.rollback()
  867. raise
  868. finally:
  869. connection.close()
  870. def load_operator_command_by_source(source_message_id: str) -> dict[str, Any] | None:
  871. connection = connect()
  872. try:
  873. with connection.cursor() as cursor:
  874. cursor.execute(
  875. "SELECT command_id FROM operator_command WHERE source_message_id=%s",
  876. (source_message_id,),
  877. )
  878. row = cursor.fetchone()
  879. return load_operator_command(row["command_id"]) if row else None
  880. finally:
  881. connection.close()
  882. def load_operator_command(command_id: str) -> dict[str, Any] | None:
  883. connection = connect()
  884. try:
  885. with connection.cursor() as cursor:
  886. cursor.execute(
  887. "SELECT * FROM operator_command WHERE command_id=%s",
  888. (command_id,),
  889. )
  890. row = cursor.fetchone()
  891. if row:
  892. row["target_account_ids"] = json.loads(
  893. row.get("target_account_ids") or "[]"
  894. )
  895. row["intent"] = json.loads(row.get("intent_json") or "{}")
  896. return row
  897. finally:
  898. connection.close()
  899. def update_operator_command(command_id: str, status: str, **values: Any) -> None:
  900. allowed = {"confirmed_at", "executed_at", "error_message"}
  901. unknown = set(values) - allowed
  902. if unknown:
  903. raise ValueError(f"Unsupported operator-command fields: {sorted(unknown)}")
  904. assignments = ["status=%s"]
  905. params: list[Any] = [status]
  906. for column, value in values.items():
  907. assignments.append(f"{column}=%s")
  908. params.append(value)
  909. params.append(command_id)
  910. connection = connect()
  911. try:
  912. with connection.cursor() as cursor:
  913. cursor.execute(
  914. f"UPDATE operator_command SET {', '.join(assignments)} "
  915. "WHERE command_id=%s",
  916. params,
  917. )
  918. finally:
  919. connection.close()
  920. def transition_operator_command(
  921. command_id: str,
  922. *,
  923. expected_statuses: set[str],
  924. target_status: str,
  925. **values: Any,
  926. ) -> bool:
  927. allowed = {"confirmed_at", "executed_at", "error_message"}
  928. unknown = set(values) - allowed
  929. if unknown:
  930. raise ValueError(f"Unsupported operator-command fields: {sorted(unknown)}")
  931. if not expected_statuses:
  932. raise ValueError("expected_statuses must not be empty")
  933. assignments = ["status=%s"]
  934. params: list[Any] = [target_status]
  935. for column, value in values.items():
  936. assignments.append(f"{column}=%s")
  937. params.append(value)
  938. placeholders = ", ".join(["%s"] * len(expected_statuses))
  939. params.extend([command_id, *sorted(expected_statuses)])
  940. connection = connect()
  941. try:
  942. with connection.cursor() as cursor:
  943. affected = cursor.execute(
  944. f"""
  945. UPDATE operator_command
  946. SET {', '.join(assignments)}
  947. WHERE command_id=%s
  948. AND status IN ({placeholders})
  949. """,
  950. params,
  951. )
  952. return affected == 1
  953. finally:
  954. connection.close()
  955. def insert_operator_command_item(record: dict[str, Any]) -> None:
  956. columns = [
  957. "command_id",
  958. "account_id",
  959. "audience_name",
  960. "adgroup_id",
  961. "adgroup_name",
  962. "before_status",
  963. "target_status",
  964. "readback_status",
  965. "execution_status",
  966. "error_message",
  967. ]
  968. connection = connect()
  969. try:
  970. with connection.cursor() as cursor:
  971. cursor.execute(
  972. f"INSERT INTO operator_command_item ({', '.join(columns)}) "
  973. f"VALUES ({', '.join(['%s'] * len(columns))})",
  974. [record.get(column) for column in columns],
  975. )
  976. finally:
  977. connection.close()
  978. def load_operator_command_items(command_id: str) -> list[dict[str, Any]]:
  979. connection = connect()
  980. try:
  981. with connection.cursor() as cursor:
  982. cursor.execute(
  983. """
  984. SELECT * FROM operator_command_item
  985. WHERE command_id=%s
  986. ORDER BY account_id, adgroup_id, id
  987. """,
  988. (command_id,),
  989. )
  990. return list(cursor.fetchall())
  991. finally:
  992. connection.close()
  993. def update_operator_command_item(item_id: int, **values: Any) -> None:
  994. allowed = {
  995. "adgroup_id", "adgroup_name", "before_status", "target_status",
  996. "readback_status", "execution_status", "error_message",
  997. }
  998. unknown = set(values) - allowed
  999. if unknown:
  1000. raise ValueError(f"Unsupported operator-command item fields: {sorted(unknown)}")
  1001. if not values:
  1002. return
  1003. assignments = [f"{column}=%s" for column in values]
  1004. connection = connect()
  1005. try:
  1006. with connection.cursor() as cursor:
  1007. cursor.execute(
  1008. f"UPDATE operator_command_item SET {', '.join(assignments)} WHERE id=%s",
  1009. [*values.values(), item_id],
  1010. )
  1011. finally:
  1012. connection.close()
  1013. def find_pending_command_conflict(
  1014. targets: list[tuple[int, int]],
  1015. *,
  1016. now: datetime,
  1017. ) -> dict[str, Any] | None:
  1018. if not targets:
  1019. return None
  1020. connection = connect()
  1021. try:
  1022. with connection.cursor() as cursor:
  1023. for start in range(0, len(targets), 200):
  1024. chunk = targets[start:start + 200]
  1025. conditions = " OR ".join(
  1026. ["(i.account_id=%s AND i.adgroup_id=%s)"] * len(chunk)
  1027. )
  1028. params: list[Any] = []
  1029. for account_id, adgroup_id in chunk:
  1030. params.extend([account_id, adgroup_id])
  1031. params.append(now.replace(tzinfo=None))
  1032. cursor.execute(
  1033. f"""
  1034. SELECT c.command_id, c.action, i.account_id, i.adgroup_id
  1035. FROM operator_command c
  1036. JOIN operator_command_item i ON i.command_id=c.command_id
  1037. WHERE ({conditions})
  1038. AND (
  1039. (c.status='PENDING_CONFIRMATION' AND c.expires_at >= %s)
  1040. OR c.status='EXECUTING'
  1041. )
  1042. ORDER BY c.created_at
  1043. LIMIT 1
  1044. """,
  1045. params,
  1046. )
  1047. conflict = cursor.fetchone()
  1048. if conflict:
  1049. return conflict
  1050. return None
  1051. finally:
  1052. connection.close()
  1053. def list_pending_operator_commands(
  1054. chat_id: str,
  1055. sender_open_id: str,
  1056. now: datetime,
  1057. ) -> list[dict[str, Any]]:
  1058. connection = connect()
  1059. try:
  1060. with connection.cursor() as cursor:
  1061. cursor.execute(
  1062. """
  1063. SELECT command_id, action, preview_account_count, preview_ad_count,
  1064. preview_cost_fen, expires_at
  1065. FROM operator_command
  1066. WHERE chat_id=%s AND sender_open_id=%s
  1067. AND status='PENDING_CONFIRMATION' AND expires_at >= %s
  1068. ORDER BY created_at
  1069. """,
  1070. (chat_id, sender_open_id, now.replace(tzinfo=None)),
  1071. )
  1072. return list(cursor.fetchall())
  1073. finally:
  1074. connection.close()
  1075. def load_active_operator_draft(
  1076. chat_id: str,
  1077. sender_open_id: str,
  1078. now: datetime,
  1079. ) -> dict[str, Any] | None:
  1080. connection = connect()
  1081. try:
  1082. with connection.cursor() as cursor:
  1083. cursor.execute(
  1084. """
  1085. SELECT * FROM operator_command_draft
  1086. WHERE chat_id=%s AND sender_open_id=%s
  1087. AND status='ACTIVE' AND expires_at >= %s
  1088. """,
  1089. (chat_id, sender_open_id, now.replace(tzinfo=None)),
  1090. )
  1091. row = cursor.fetchone()
  1092. if row:
  1093. for field in ("account_ids", "missing_fields", "source_message_ids"):
  1094. row[field] = json.loads(row.get(field) or "[]")
  1095. return row
  1096. finally:
  1097. connection.close()
  1098. def save_operator_draft(record: dict[str, Any]) -> dict[str, Any]:
  1099. connection = connect()
  1100. try:
  1101. with connection.cursor() as cursor:
  1102. cursor.execute(
  1103. """
  1104. INSERT INTO operator_command_draft
  1105. (draft_id, chat_id, sender_open_id, raw_text, action, scope_type,
  1106. account_ids, missing_fields, source_message_ids, status, expires_at)
  1107. VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,'ACTIVE',%s)
  1108. ON DUPLICATE KEY UPDATE
  1109. draft_id=VALUES(draft_id), raw_text=VALUES(raw_text),
  1110. action=VALUES(action),
  1111. scope_type=VALUES(scope_type), account_ids=VALUES(account_ids),
  1112. missing_fields=VALUES(missing_fields),
  1113. source_message_ids=VALUES(source_message_ids),
  1114. status='ACTIVE', expires_at=VALUES(expires_at)
  1115. """,
  1116. (
  1117. record["draft_id"], record["chat_id"], record["sender_open_id"],
  1118. record.get("raw_text"), record.get("action"),
  1119. record.get("scope_type", "MISSING"),
  1120. json.dumps(record.get("account_ids") or []),
  1121. json.dumps(record.get("missing_fields") or []),
  1122. json.dumps(record.get("source_message_ids") or []),
  1123. record["expires_at"],
  1124. ),
  1125. )
  1126. return record
  1127. finally:
  1128. connection.close()
  1129. def close_operator_draft(chat_id: str, sender_open_id: str, status: str) -> None:
  1130. if status not in {"COMPLETED", "CANCELLED", "SUPERSEDED", "EXPIRED"}:
  1131. raise ValueError(f"Unsupported draft status: {status}")
  1132. connection = connect()
  1133. try:
  1134. with connection.cursor() as cursor:
  1135. cursor.execute(
  1136. """
  1137. UPDATE operator_command_draft SET status=%s
  1138. WHERE chat_id=%s AND sender_open_id=%s AND status='ACTIVE'
  1139. """,
  1140. (status, chat_id, sender_open_id),
  1141. )
  1142. finally:
  1143. connection.close()
  1144. def set_operator_pause(
  1145. *,
  1146. account_id: int,
  1147. adgroup_id: int,
  1148. mode: str,
  1149. resume_at: datetime | None,
  1150. command_id: str,
  1151. paused_from_status: str,
  1152. paused_at: datetime,
  1153. ) -> None:
  1154. connection = connect()
  1155. try:
  1156. with connection.cursor() as cursor:
  1157. cursor.execute(
  1158. """
  1159. UPDATE realtime_control_ad_state
  1160. SET operator_pause_mode=%s,
  1161. operator_resume_at=%s,
  1162. operator_command_id=%s,
  1163. operator_paused_from_status=%s,
  1164. operator_paused_at=%s,
  1165. last_action='OPERATOR_PAUSE',
  1166. last_action_at=%s,
  1167. last_seen_at=%s
  1168. WHERE account_id=%s AND adgroup_id=%s
  1169. """,
  1170. (
  1171. mode,
  1172. resume_at,
  1173. command_id,
  1174. paused_from_status,
  1175. paused_at,
  1176. paused_at,
  1177. paused_at,
  1178. account_id,
  1179. adgroup_id,
  1180. ),
  1181. )
  1182. finally:
  1183. connection.close()
  1184. def clear_operator_pause(
  1185. account_id: int,
  1186. adgroup_id: int,
  1187. *,
  1188. action: str,
  1189. action_at: datetime,
  1190. ) -> None:
  1191. connection = connect()
  1192. try:
  1193. with connection.cursor() as cursor:
  1194. cursor.execute(
  1195. """
  1196. UPDATE realtime_control_ad_state
  1197. SET operator_pause_mode=NULL,
  1198. operator_resume_at=NULL,
  1199. operator_command_id=NULL,
  1200. operator_paused_from_status=NULL,
  1201. operator_paused_at=NULL,
  1202. last_action=%s,
  1203. last_action_at=%s,
  1204. last_seen_at=%s
  1205. WHERE account_id=%s AND adgroup_id=%s
  1206. """,
  1207. (action, action_at, action_at, account_id, adgroup_id),
  1208. )
  1209. finally:
  1210. connection.close()
  1211. def load_operator_pauses(
  1212. account_ids: list[int] | None = None,
  1213. ) -> list[dict[str, Any]]:
  1214. params: list[Any] = []
  1215. where = "WHERE operator_pause_mode IS NOT NULL"
  1216. if account_ids is not None:
  1217. if not account_ids:
  1218. return []
  1219. where += f" AND account_id IN ({', '.join(['%s'] * len(account_ids))})"
  1220. params.extend(account_ids)
  1221. connection = connect()
  1222. try:
  1223. with connection.cursor() as cursor:
  1224. cursor.execute(
  1225. f"""
  1226. SELECT *
  1227. FROM realtime_control_ad_state
  1228. {where}
  1229. ORDER BY account_id, adgroup_id
  1230. """,
  1231. params,
  1232. )
  1233. return list(cursor.fetchall())
  1234. finally:
  1235. connection.close()