storage.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831
  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. roi_migrations = {
  94. "roi_metric_run": {
  95. "fission_parameter_version": "VARCHAR(64) DEFAULT NULL",
  96. "fission_cohort_date": "DATE DEFAULT NULL",
  97. },
  98. "roi_entity_snapshot": {
  99. "conversion_goal": "VARCHAR(255) DEFAULT NULL",
  100. "fission_parameter_version": "VARCHAR(64) DEFAULT NULL",
  101. "fission_cohort_date": "VARCHAR(8) DEFAULT NULL",
  102. "fission_multiplier_vs_t0": "DECIMAL(18,8) DEFAULT NULL",
  103. "fission_multiplier_vs_first": "DECIMAL(18,8) DEFAULT NULL",
  104. "fission_match_level": "VARCHAR(64) DEFAULT NULL",
  105. "fission_source": "VARCHAR(512) DEFAULT NULL",
  106. "actual_total_revenue": "DECIMAL(20,4) DEFAULT NULL",
  107. "actual_roi": "DECIMAL(18,8) DEFAULT NULL",
  108. "predicted_tail_revenue": "DECIMAL(20,4) DEFAULT NULL",
  109. "predicted_fission_revenue": "DECIMAL(20,4) DEFAULT NULL",
  110. },
  111. "roi_fission_parameter_value": {
  112. "multiplier_vs_first": "DOUBLE DEFAULT NULL",
  113. },
  114. "roi_action_item": {
  115. "approval_status": "VARCHAR(32) NOT NULL DEFAULT 'PENDING'",
  116. "approval_source": "VARCHAR(32) DEFAULT NULL",
  117. "approved_at": "DATETIME DEFAULT NULL",
  118. "rejected_at": "DATETIME DEFAULT NULL",
  119. "sheet_row_number": "INT DEFAULT NULL",
  120. "result_notified_at": "DATETIME DEFAULT NULL",
  121. "notification_error": "TEXT DEFAULT NULL",
  122. },
  123. }
  124. for table_name, columns in roi_migrations.items():
  125. cursor.execute(
  126. """
  127. SELECT COLUMN_NAME
  128. FROM information_schema.COLUMNS
  129. WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s
  130. """,
  131. (os.environ["DB_NAME"], table_name),
  132. )
  133. table_columns = {
  134. row["COLUMN_NAME"] for row in cursor.fetchall()
  135. }
  136. for column, definition in columns.items():
  137. if column not in table_columns:
  138. cursor.execute(
  139. f"ALTER TABLE {table_name} "
  140. f"ADD COLUMN {column} {definition}"
  141. )
  142. cursor.execute(
  143. """
  144. SELECT INDEX_NAME
  145. FROM information_schema.STATISTICS
  146. WHERE TABLE_SCHEMA=%s
  147. AND TABLE_NAME='roi_action_item'
  148. AND INDEX_NAME='idx_roi_action_approval'
  149. """,
  150. (os.environ["DB_NAME"],),
  151. )
  152. if not cursor.fetchone():
  153. cursor.execute(
  154. """
  155. CREATE INDEX idx_roi_action_approval
  156. ON roi_action_item (run_id, approval_status)
  157. """
  158. )
  159. finally:
  160. connection.close()
  161. @contextmanager
  162. def advisory_lock(lock_name: str) -> Iterator[bool]:
  163. connection = connect()
  164. acquired = False
  165. try:
  166. with connection.cursor() as cursor:
  167. cursor.execute("SELECT GET_LOCK(%s, 0) AS acquired", (lock_name,))
  168. acquired = bool((cursor.fetchone() or {}).get("acquired"))
  169. yield acquired
  170. finally:
  171. if acquired:
  172. try:
  173. with connection.cursor() as cursor:
  174. cursor.execute("SELECT RELEASE_LOCK(%s)", (lock_name,))
  175. except Exception:
  176. pass
  177. connection.close()
  178. def load_enabled_accounts() -> list[dict[str, Any]]:
  179. connection = connect()
  180. try:
  181. with connection.cursor() as cursor:
  182. cursor.execute(
  183. """
  184. SELECT c.account_id, c.audience_name, c.bid_scene
  185. FROM ad_creation_account_config c
  186. JOIN account_whitelist w ON w.account_id = c.account_id
  187. WHERE c.enabled = TRUE
  188. AND w.enabled = TRUE
  189. ORDER BY c.account_id
  190. """
  191. )
  192. return list(cursor.fetchall())
  193. finally:
  194. connection.close()
  195. def load_realtime_accounts() -> list[dict[str, Any]]:
  196. """Load automation accounts plus explicit real-time scope overrides."""
  197. connection = connect()
  198. try:
  199. with connection.cursor() as cursor:
  200. cursor.execute(
  201. """
  202. SELECT c.account_id, c.audience_name, c.bid_scene,
  203. 'FULL' AS control_mode
  204. FROM ad_creation_account_config c
  205. JOIN account_whitelist w ON w.account_id = c.account_id
  206. WHERE w.enabled = TRUE
  207. ORDER BY c.account_id
  208. """
  209. )
  210. accounts = {
  211. int(row["account_id"]): row for row in cursor.fetchall()
  212. }
  213. cursor.execute(
  214. """
  215. SELECT account_id, audience_name, bid_scene, control_mode
  216. FROM realtime_control_account_scope
  217. WHERE enabled=TRUE
  218. ORDER BY account_id
  219. """
  220. )
  221. for row in cursor.fetchall():
  222. accounts[int(row["account_id"])] = row
  223. return [accounts[key] for key in sorted(accounts)]
  224. finally:
  225. connection.close()
  226. def upsert_realtime_account_scope(
  227. *,
  228. account_id: int,
  229. control_mode: str,
  230. audience_name: str,
  231. bid_scene: str | None,
  232. source: str,
  233. note: str,
  234. ) -> None:
  235. if control_mode not in {"FULL", "PAUSE_ONLY"}:
  236. raise ValueError(f"Unsupported real-time control mode: {control_mode}")
  237. connection = connect()
  238. try:
  239. with connection.cursor() as cursor:
  240. cursor.execute(
  241. """
  242. INSERT INTO realtime_control_account_scope
  243. (account_id, control_mode, audience_name, bid_scene,
  244. enabled, source, note)
  245. VALUES (%s,%s,%s,%s,TRUE,%s,%s)
  246. ON DUPLICATE KEY UPDATE
  247. control_mode=VALUES(control_mode),
  248. audience_name=VALUES(audience_name),
  249. bid_scene=VALUES(bid_scene),
  250. enabled=TRUE,
  251. source=VALUES(source),
  252. note=VALUES(note)
  253. """,
  254. (
  255. account_id,
  256. control_mode,
  257. audience_name,
  258. bid_scene,
  259. source,
  260. note,
  261. ),
  262. )
  263. finally:
  264. connection.close()
  265. def load_managed_accounts() -> list[dict[str, Any]]:
  266. """Historical automation accounts still allowed by the account whitelist."""
  267. connection = connect()
  268. try:
  269. with connection.cursor() as cursor:
  270. cursor.execute(
  271. """
  272. SELECT c.account_id, c.audience_name, c.bid_scene, c.enabled
  273. FROM ad_creation_account_config c
  274. JOIN account_whitelist w ON w.account_id = c.account_id
  275. WHERE w.enabled = TRUE
  276. ORDER BY c.account_id
  277. """
  278. )
  279. return list(cursor.fetchall())
  280. finally:
  281. connection.close()
  282. def load_daily_state(control_date: date) -> dict[str, Any]:
  283. connection = connect()
  284. try:
  285. with connection.cursor() as cursor:
  286. cursor.execute(
  287. "SELECT * FROM realtime_control_daily_state WHERE control_date=%s",
  288. (control_date,),
  289. )
  290. return cursor.fetchone() or {}
  291. finally:
  292. connection.close()
  293. def save_daily_state(control_date: date, **values: Any) -> None:
  294. allowed = {
  295. "morning_recovery_done",
  296. "cutoff_done",
  297. "last_observed_partition",
  298. "last_observed_cpm",
  299. "last_decision",
  300. "last_inventory_refresh_at",
  301. "last_evaluated_at",
  302. }
  303. unknown = set(values) - allowed
  304. if unknown:
  305. raise ValueError(f"Unsupported daily-state fields: {sorted(unknown)}")
  306. columns = ["control_date", *values]
  307. params = [control_date, *values.values()]
  308. updates = ", ".join(f"{column}=VALUES({column})" for column in values)
  309. placeholders = ", ".join(["%s"] * len(columns))
  310. sql = (
  311. f"INSERT INTO realtime_control_daily_state ({', '.join(columns)}) "
  312. f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}"
  313. )
  314. connection = connect()
  315. try:
  316. with connection.cursor() as cursor:
  317. cursor.execute(sql, params)
  318. finally:
  319. connection.close()
  320. def load_ad_states(account_id: int) -> dict[int, dict[str, Any]]:
  321. connection = connect()
  322. try:
  323. with connection.cursor() as cursor:
  324. cursor.execute(
  325. "SELECT * FROM realtime_control_ad_state WHERE account_id=%s",
  326. (account_id,),
  327. )
  328. return {int(row["adgroup_id"]): row for row in cursor.fetchall()}
  329. finally:
  330. connection.close()
  331. def upsert_ad_state(
  332. *,
  333. account_id: int,
  334. adgroup_id: int,
  335. adgroup_name: str,
  336. bid_field: str,
  337. base_bid_fen: int,
  338. boosted_date: date | None,
  339. paused_by_strategy: bool,
  340. pause_reason: str | None,
  341. last_action: str,
  342. action_at: datetime,
  343. ) -> None:
  344. connection = connect()
  345. try:
  346. with connection.cursor() as cursor:
  347. cursor.execute(
  348. """
  349. INSERT INTO realtime_control_ad_state
  350. (account_id, adgroup_id, adgroup_name, bid_field, base_bid_fen,
  351. initial_base_bid_fen,
  352. boosted_date, paused_by_strategy, pause_reason, last_action,
  353. last_action_at, last_seen_at)
  354. VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
  355. ON DUPLICATE KEY UPDATE
  356. adgroup_name=VALUES(adgroup_name),
  357. bid_field=VALUES(bid_field),
  358. initial_base_bid_fen=COALESCE(
  359. initial_base_bid_fen,
  360. VALUES(initial_base_bid_fen)
  361. ),
  362. boosted_date=VALUES(boosted_date),
  363. paused_by_strategy=VALUES(paused_by_strategy),
  364. pause_reason=VALUES(pause_reason),
  365. last_action=VALUES(last_action),
  366. last_action_at=VALUES(last_action_at),
  367. last_seen_at=VALUES(last_seen_at)
  368. """,
  369. (
  370. account_id,
  371. adgroup_id,
  372. adgroup_name,
  373. bid_field,
  374. base_bid_fen,
  375. base_bid_fen,
  376. boosted_date,
  377. paused_by_strategy,
  378. pause_reason,
  379. last_action,
  380. action_at,
  381. action_at,
  382. ),
  383. )
  384. finally:
  385. connection.close()
  386. def sync_roi_base_bid(
  387. *,
  388. account_id: int,
  389. adgroup_id: int,
  390. adgroup_name: str,
  391. bid_field: str,
  392. initial_base_bid_fen: int,
  393. new_base_bid_fen: int,
  394. boosted_date: date | None,
  395. action_at: datetime,
  396. ) -> None:
  397. """Persist a permanent ROI base bid without losing intraday boost state."""
  398. connection = connect()
  399. try:
  400. with connection.cursor() as cursor:
  401. cursor.execute(
  402. """
  403. INSERT INTO realtime_control_ad_state
  404. (account_id, adgroup_id, adgroup_name, bid_field,
  405. base_bid_fen, initial_base_bid_fen, boosted_date,
  406. paused_by_strategy, pause_reason, last_action,
  407. last_action_at, last_seen_at, last_roi_scaled_at)
  408. VALUES (%s,%s,%s,%s,%s,%s,%s,FALSE,NULL,'ROI_SCALE',%s,%s,%s)
  409. ON DUPLICATE KEY UPDATE
  410. adgroup_name=VALUES(adgroup_name),
  411. bid_field=VALUES(bid_field),
  412. base_bid_fen=VALUES(base_bid_fen),
  413. initial_base_bid_fen=COALESCE(
  414. initial_base_bid_fen,
  415. VALUES(initial_base_bid_fen)
  416. ),
  417. boosted_date=VALUES(boosted_date),
  418. last_action='ROI_SCALE',
  419. last_action_at=VALUES(last_action_at),
  420. last_seen_at=VALUES(last_seen_at),
  421. last_roi_scaled_at=VALUES(last_roi_scaled_at)
  422. """,
  423. (
  424. account_id,
  425. adgroup_id,
  426. adgroup_name,
  427. bid_field,
  428. new_base_bid_fen,
  429. initial_base_bid_fen,
  430. boosted_date,
  431. action_at,
  432. action_at,
  433. action_at,
  434. ),
  435. )
  436. finally:
  437. connection.close()
  438. def set_bid_experiment_hold(
  439. *,
  440. account_id: int,
  441. adgroup_id: int,
  442. adgroup_name: str,
  443. bid_field: str,
  444. base_bid_fen: int,
  445. action_at: datetime,
  446. reason: str,
  447. ) -> None:
  448. """Persist an experiment base bid and prevent real-time bid overrides."""
  449. connection = connect()
  450. try:
  451. with connection.cursor() as cursor:
  452. cursor.execute(
  453. """
  454. INSERT INTO realtime_control_ad_state
  455. (account_id, adgroup_id, adgroup_name, bid_field,
  456. base_bid_fen, initial_base_bid_fen, boosted_date,
  457. bid_hold, bid_hold_reason, paused_by_strategy,
  458. last_action, last_action_at, last_seen_at)
  459. VALUES (%s,%s,%s,%s,%s,%s,%s,TRUE,%s,FALSE,
  460. 'BID_EXPERIMENT_HOLD',%s,%s)
  461. ON DUPLICATE KEY UPDATE
  462. adgroup_name=VALUES(adgroup_name),
  463. bid_field=VALUES(bid_field),
  464. base_bid_fen=VALUES(base_bid_fen),
  465. initial_base_bid_fen=COALESCE(
  466. initial_base_bid_fen,
  467. VALUES(initial_base_bid_fen)
  468. ),
  469. boosted_date=VALUES(boosted_date),
  470. bid_hold=TRUE,
  471. bid_hold_reason=VALUES(bid_hold_reason),
  472. last_action='BID_EXPERIMENT_HOLD',
  473. last_action_at=VALUES(last_action_at),
  474. last_seen_at=VALUES(last_seen_at)
  475. """,
  476. (
  477. account_id,
  478. adgroup_id,
  479. adgroup_name,
  480. bid_field,
  481. base_bid_fen,
  482. base_bid_fen,
  483. action_at.date(),
  484. reason,
  485. action_at,
  486. action_at,
  487. ),
  488. )
  489. finally:
  490. connection.close()
  491. def clear_bid_experiment_hold(
  492. account_id: int,
  493. adgroup_id: int,
  494. *,
  495. action_at: datetime,
  496. ) -> None:
  497. connection = connect()
  498. try:
  499. with connection.cursor() as cursor:
  500. cursor.execute(
  501. """
  502. UPDATE realtime_control_ad_state
  503. SET bid_hold=FALSE,
  504. bid_hold_reason=NULL,
  505. last_action='BID_EXPERIMENT_RELEASE',
  506. last_action_at=%s,
  507. last_seen_at=%s
  508. WHERE account_id=%s AND adgroup_id=%s
  509. """,
  510. (action_at, action_at, account_id, adgroup_id),
  511. )
  512. if cursor.rowcount != 1:
  513. raise ValueError(
  514. "未找到实验出价状态: "
  515. f"account={account_id} adgroup={adgroup_id}"
  516. )
  517. finally:
  518. connection.close()
  519. def insert_action_log(record: dict[str, Any]) -> None:
  520. columns = [
  521. "run_id",
  522. "control_date",
  523. "observed_partition",
  524. "observed_cpm",
  525. "decision",
  526. "account_id",
  527. "adgroup_id",
  528. "adgroup_name",
  529. "bid_field",
  530. "base_bid_fen",
  531. "before_bid_fen",
  532. "target_bid_fen",
  533. "before_status",
  534. "target_status",
  535. "apply_mode",
  536. "execution_status",
  537. "error_message",
  538. ]
  539. connection = connect()
  540. try:
  541. with connection.cursor() as cursor:
  542. cursor.execute(
  543. f"INSERT INTO realtime_control_action_log ({', '.join(columns)}) "
  544. f"VALUES ({', '.join(['%s'] * len(columns))})",
  545. [record.get(column) for column in columns],
  546. )
  547. finally:
  548. connection.close()
  549. def create_operator_command(record: dict[str, Any]) -> dict[str, Any]:
  550. connection = connect()
  551. try:
  552. with connection.cursor() as cursor:
  553. try:
  554. cursor.execute(
  555. """
  556. INSERT INTO operator_command
  557. (command_id, source_message_id, chat_id, sender_open_id,
  558. sender_name, action, scope_type, target_account_ids,
  559. status, preview_account_count, preview_ad_count, expires_at)
  560. VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
  561. """,
  562. (
  563. record["command_id"],
  564. record["source_message_id"],
  565. record["chat_id"],
  566. record["sender_open_id"],
  567. record.get("sender_name"),
  568. record["action"],
  569. record["scope_type"],
  570. json.dumps(record["target_account_ids"]),
  571. record["status"],
  572. record.get("preview_account_count", 0),
  573. record.get("preview_ad_count", 0),
  574. record.get("expires_at"),
  575. ),
  576. )
  577. except pymysql.err.IntegrityError:
  578. cursor.execute(
  579. "SELECT * FROM operator_command WHERE source_message_id=%s",
  580. (record["source_message_id"],),
  581. )
  582. existing = cursor.fetchone()
  583. if existing:
  584. existing["target_account_ids"] = json.loads(
  585. existing.get("target_account_ids") or "[]"
  586. )
  587. return existing
  588. raise
  589. return load_operator_command(record["command_id"]) or {}
  590. finally:
  591. connection.close()
  592. def load_operator_command(command_id: str) -> dict[str, Any] | None:
  593. connection = connect()
  594. try:
  595. with connection.cursor() as cursor:
  596. cursor.execute(
  597. "SELECT * FROM operator_command WHERE command_id=%s",
  598. (command_id,),
  599. )
  600. row = cursor.fetchone()
  601. if row:
  602. row["target_account_ids"] = json.loads(
  603. row.get("target_account_ids") or "[]"
  604. )
  605. return row
  606. finally:
  607. connection.close()
  608. def update_operator_command(command_id: str, status: str, **values: Any) -> None:
  609. allowed = {"confirmed_at", "executed_at", "error_message"}
  610. unknown = set(values) - allowed
  611. if unknown:
  612. raise ValueError(f"Unsupported operator-command fields: {sorted(unknown)}")
  613. assignments = ["status=%s"]
  614. params: list[Any] = [status]
  615. for column, value in values.items():
  616. assignments.append(f"{column}=%s")
  617. params.append(value)
  618. params.append(command_id)
  619. connection = connect()
  620. try:
  621. with connection.cursor() as cursor:
  622. cursor.execute(
  623. f"UPDATE operator_command SET {', '.join(assignments)} "
  624. "WHERE command_id=%s",
  625. params,
  626. )
  627. finally:
  628. connection.close()
  629. def transition_operator_command(
  630. command_id: str,
  631. *,
  632. expected_statuses: set[str],
  633. target_status: str,
  634. **values: Any,
  635. ) -> bool:
  636. allowed = {"confirmed_at", "executed_at", "error_message"}
  637. unknown = set(values) - allowed
  638. if unknown:
  639. raise ValueError(f"Unsupported operator-command fields: {sorted(unknown)}")
  640. if not expected_statuses:
  641. raise ValueError("expected_statuses must not be empty")
  642. assignments = ["status=%s"]
  643. params: list[Any] = [target_status]
  644. for column, value in values.items():
  645. assignments.append(f"{column}=%s")
  646. params.append(value)
  647. placeholders = ", ".join(["%s"] * len(expected_statuses))
  648. params.extend([command_id, *sorted(expected_statuses)])
  649. connection = connect()
  650. try:
  651. with connection.cursor() as cursor:
  652. affected = cursor.execute(
  653. f"""
  654. UPDATE operator_command
  655. SET {', '.join(assignments)}
  656. WHERE command_id=%s
  657. AND status IN ({placeholders})
  658. """,
  659. params,
  660. )
  661. return affected == 1
  662. finally:
  663. connection.close()
  664. def insert_operator_command_item(record: dict[str, Any]) -> None:
  665. columns = [
  666. "command_id",
  667. "account_id",
  668. "audience_name",
  669. "adgroup_id",
  670. "adgroup_name",
  671. "before_status",
  672. "target_status",
  673. "readback_status",
  674. "execution_status",
  675. "error_message",
  676. ]
  677. connection = connect()
  678. try:
  679. with connection.cursor() as cursor:
  680. cursor.execute(
  681. f"INSERT INTO operator_command_item ({', '.join(columns)}) "
  682. f"VALUES ({', '.join(['%s'] * len(columns))})",
  683. [record.get(column) for column in columns],
  684. )
  685. finally:
  686. connection.close()
  687. def set_operator_pause(
  688. *,
  689. account_id: int,
  690. adgroup_id: int,
  691. mode: str,
  692. resume_at: datetime | None,
  693. command_id: str,
  694. paused_from_status: str,
  695. paused_at: datetime,
  696. ) -> None:
  697. connection = connect()
  698. try:
  699. with connection.cursor() as cursor:
  700. cursor.execute(
  701. """
  702. UPDATE realtime_control_ad_state
  703. SET operator_pause_mode=%s,
  704. operator_resume_at=%s,
  705. operator_command_id=%s,
  706. operator_paused_from_status=%s,
  707. operator_paused_at=%s,
  708. last_action='OPERATOR_PAUSE',
  709. last_action_at=%s,
  710. last_seen_at=%s
  711. WHERE account_id=%s AND adgroup_id=%s
  712. """,
  713. (
  714. mode,
  715. resume_at,
  716. command_id,
  717. paused_from_status,
  718. paused_at,
  719. paused_at,
  720. paused_at,
  721. account_id,
  722. adgroup_id,
  723. ),
  724. )
  725. finally:
  726. connection.close()
  727. def clear_operator_pause(
  728. account_id: int,
  729. adgroup_id: int,
  730. *,
  731. action: str,
  732. action_at: datetime,
  733. ) -> None:
  734. connection = connect()
  735. try:
  736. with connection.cursor() as cursor:
  737. cursor.execute(
  738. """
  739. UPDATE realtime_control_ad_state
  740. SET operator_pause_mode=NULL,
  741. operator_resume_at=NULL,
  742. operator_command_id=NULL,
  743. operator_paused_from_status=NULL,
  744. operator_paused_at=NULL,
  745. last_action=%s,
  746. last_action_at=%s,
  747. last_seen_at=%s
  748. WHERE account_id=%s AND adgroup_id=%s
  749. """,
  750. (action, action_at, action_at, account_id, adgroup_id),
  751. )
  752. finally:
  753. connection.close()
  754. def load_operator_pauses(
  755. account_ids: list[int] | None = None,
  756. ) -> list[dict[str, Any]]:
  757. params: list[Any] = []
  758. where = "WHERE operator_pause_mode IS NOT NULL"
  759. if account_ids is not None:
  760. if not account_ids:
  761. return []
  762. where += f" AND account_id IN ({', '.join(['%s'] * len(account_ids))})"
  763. params.extend(account_ids)
  764. connection = connect()
  765. try:
  766. with connection.cursor() as cursor:
  767. cursor.execute(
  768. f"""
  769. SELECT *
  770. FROM realtime_control_ad_state
  771. {where}
  772. ORDER BY account_id, adgroup_id
  773. """,
  774. params,
  775. )
  776. return list(cursor.fetchall())
  777. finally:
  778. connection.close()