diff --git a/scripts/sms_first_campaign.py b/scripts/sms_first_campaign.py index 192701b..3c808b2 100644 --- a/scripts/sms_first_campaign.py +++ b/scripts/sms_first_campaign.py @@ -35,10 +35,14 @@ import psycopg2 from psycopg2.extras import RealDictCursor try: # лимитер sms.ru (модуль лежит рядом со скриптом) - from sms_rate_limit import check_send_allowed, alert_due + from sms_rate_limit import ( + check_send_allowed, alert_due, acquire_send_lock, release_send_lock, + ) except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше check_send_allowed = None alert_due = None + acquire_send_lock = None + release_send_lock = None # ─── Конфигурация ──────────────────────────────────────────────────────────── @@ -429,6 +433,14 @@ def step_send_new(conn, settings): groups = get_groups_to_send(conn, max_attempts) log.info(f"Step 1: {len(groups)} groups to send SMS") + + # --- Сериализация отправки: аккаунтный лимит sms.ru общий для всех кампаний --- + _send_lock = False + if acquire_send_lock is not None: + _send_lock = acquire_send_lock(conn) + if not _send_lock: + log.warning("Прогон пропущен: не удалось взять блокировку отправки") + return 0 sent_count = 0 limited_count = 0 @@ -546,7 +558,8 @@ def step_send_new(conn, settings): _acct = (limited_info or {}).get("account") msg = ( f"SMS придержаны лимитом sms.ru: {limited_count} шт. " - f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); " + f"(окно {_win}с, лимит аккаунта {_acct} суммарно, " + f"страховка {_cap} на номер); " f"причина: {limited_last}; " f"сообщения не потеряны — уйдут в следующий запуск." ) @@ -556,6 +569,9 @@ def step_send_new(conn, settings): else: log.info("Rate limit Telegram alert suppressed (throttled)") + if _send_lock and release_send_lock is not None: + release_send_lock(conn) + return sent_count def step_check_status(conn, settings): diff --git a/scripts/sms_paid_storage_campaign.py b/scripts/sms_paid_storage_campaign.py index 1b8f7c1..333f893 100644 --- a/scripts/sms_paid_storage_campaign.py +++ b/scripts/sms_paid_storage_campaign.py @@ -28,10 +28,14 @@ import psycopg2 from psycopg2.extras import RealDictCursor try: # лимитер sms.ru (модуль лежит рядом со скриптом) - from sms_rate_limit import check_send_allowed, alert_due + from sms_rate_limit import ( + check_send_allowed, alert_due, acquire_send_lock, release_send_lock, + ) except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше check_send_allowed = None alert_due = None + acquire_send_lock = None + release_send_lock = None # ─── Конфигурация ──────────────────────────────────────────────────────────── @@ -369,6 +373,14 @@ def step_send_new(conn, settings): groups = get_groups_to_send(conn) log.info(f"Step 1: {len(groups)} groups to send paid_storage SMS") + # --- Сериализация отправки: аккаунтный лимит sms.ru общий для всех кампаний --- + _send_lock = False + if acquire_send_lock is not None: + _send_lock = acquire_send_lock(conn) + if not _send_lock: + log.warning("Прогон пропущен: не удалось взять блокировку отправки") + return 0 + sent_count = 0 limited_count = 0 limited_last = None @@ -452,7 +464,8 @@ def step_send_new(conn, settings): _acct = (limited_info or {}).get("account") msg = ( f"SMS придержаны лимитом sms.ru: {limited_count} шт. " - f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); " + f"(окно {_win}с, лимит аккаунта {_acct} суммарно, " + f"страховка {_cap} на номер); " f"причина: {limited_last}; " f"сообщения не потеряны — уйдут в следующий запуск." ) @@ -462,6 +475,9 @@ def step_send_new(conn, settings): else: log.info("Rate limit Telegram alert suppressed (throttled)") + if _send_lock and release_send_lock is not None: + release_send_lock(conn) + return sent_count def step_check_status(conn, settings): diff --git a/scripts/sms_rate_limit.py b/scripts/sms_rate_limit.py index 36d0451..8f2909d 100644 --- a/scripts/sms_rate_limit.py +++ b/scripts/sms_rate_limit.py @@ -1,18 +1,23 @@ #!/usr/bin/env python3 """Проактивный лимитер отправки SMS для SuperSam (правило sms.ru). -sms.ru блокирует номер/аккаунт, если на ОДИН номер уходит слишком много SMS -(«бомбинг»): по умолчанию **не более 8 сообщений на номер за 10 минут**. -Сейчас кампании узнают об этом только постфактум — по коду ошибки 231/232, -после чего группа помечается send_failed, а часть сообщений уже потеряна. -Этот модуль проверяет лимит ДО отправки и придерживает сообщение: оно не -теряется, а уходит в следующий запуск кампании. +Главное ограничение sms.ru — **не более 8 SMS за 10 минут по аккаунту +суммарно, на любые номера** (антибомбинг; при превышении отправка отклоняется +кодами 231/232). Отдельно страхуемся правилом «не более N SMS на один номер +за окно». Сейчас кампании узнают о лимите только постфактум — по коду ошибки +231/232, после чего группа помечается send_failed, а часть сообщений уже +потеряна. Этот модуль проверяет лимит ДО отправки и придерживает сообщение: +оно не теряется, а уходит в следующий запуск кампании. + +Отправку всех кампаний сериализует общая блокировка (acquire_send_lock): +аккаунтный счётчик один на всех, а таймеры кампаний стартуют в одну секунду. Правило и окно настраиваются в sms_campaign_settings (админка/БД): sms_limit_per_number_10min — сколько SMS на один номер за окно (по умолчанию 8; 0 = проверка выключена) - sms_limit_account_10min — сколько SMS всего за окно - (по умолчанию 0 = выключено) + sms_limit_account_10min — сколько SMS всего за окно, по аккаунту + (боевое значение 8 = правило sms.ru; + 0 = проверка выключена) sms_rate_window_seconds — окно в секундах (по умолчанию 600) Что считается отправкой: строки sms_campaign_log, реально принятые sms.ru @@ -141,3 +146,47 @@ def alert_due(conn, settings, throttle_seconds=ALERT_THROTTLE_SECONDS): conn.rollback() log.error("alert_due failed: %s", exc) return False + + +# ─── Блокировка отправки (сериализация кампаний) ───────────────────────────── + +SEND_LOCK_KEY = 0x53535331 # 'SSS1' — общий ключ отправки всех SMS-кампаний +LOCK_TIMEOUT_SECONDS = 120 # ждём чужой прогон не дольше двух минут + + +def acquire_send_lock(conn, timeout_seconds=LOCK_TIMEOUT_SECONDS): + """Захватить общую блокировку отправки (pg_advisory_lock). + + Аккаунтный лимит sms.ru общий для всех кампаний, а их таймеры стартуют в + одну и ту же секунду: без сериализации каждый процесс видит «свободно 8» и + вместе они отправят больше лимита. Блокировка — на соединении, снимается + сама при закрытии коннекта. + + True — блокировка взята, можно отправлять. + False — чужой прогон не завершился за timeout_seconds: прогон пропускаем + (лучше задержка, чем блокировка аккаунта у sms.ru), повторим по + таймеру. + """ + try: + with conn.cursor() as cur: + cur.execute("SET lock_timeout = %s", (f"{int(timeout_seconds)}s",)) + cur.execute("SELECT pg_advisory_lock(%s)", (SEND_LOCK_KEY,)) + return True + except Exception as e: # noqa: BLE001 — недоступная блокировка не должна ломать прогон + try: + conn.rollback() + except Exception: # noqa: BLE001 + pass + log.error("Не удалось взять блокировку отправки (%s) — прогон пропущен", e) + return False + + +def release_send_lock(conn): + """Снять блокировку отправки. Ошибки не критичны: снимет закрытие коннекта.""" + try: + with conn.cursor() as cur: + cur.execute("SELECT pg_advisory_unlock(%s)", (SEND_LOCK_KEY,)) + return True + except Exception as e: # noqa: BLE001 + log.warning("Не удалось снять блокировку отправки: %s", e) + return False diff --git a/scripts/sms_second_campaign.py b/scripts/sms_second_campaign.py index e41163c..8180af7 100644 --- a/scripts/sms_second_campaign.py +++ b/scripts/sms_second_campaign.py @@ -36,10 +36,14 @@ import psycopg2 from psycopg2.extras import RealDictCursor try: # лимитер sms.ru (модуль лежит рядом со скриптом) - from sms_rate_limit import check_send_allowed, alert_due + from sms_rate_limit import ( + check_send_allowed, alert_due, acquire_send_lock, release_send_lock, + ) except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше check_send_allowed = None alert_due = None + acquire_send_lock = None + release_send_lock = None # ─── Конфигурация ──────────────────────────────────────────────────────────── @@ -443,6 +447,14 @@ def step_send_new(conn, settings): groups = get_groups_to_send(conn, max_attempts) log.info(f"Step 1: {len(groups)} groups to send second SMS") + + # --- Сериализация отправки: аккаунтный лимит sms.ru общий для всех кампаний --- + _send_lock = False + if acquire_send_lock is not None: + _send_lock = acquire_send_lock(conn) + if not _send_lock: + log.warning("Прогон пропущен: не удалось взять блокировку отправки") + return 0 sent_count = 0 limited_count = 0 @@ -535,7 +547,8 @@ def step_send_new(conn, settings): _acct = (limited_info or {}).get("account") msg = ( f"SMS придержаны лимитом sms.ru: {limited_count} шт. " - f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); " + f"(окно {_win}с, лимит аккаунта {_acct} суммарно, " + f"страховка {_cap} на номер); " f"причина: {limited_last}; " f"сообщения не потеряны — уйдут в следующий запуск." ) @@ -545,6 +558,9 @@ def step_send_new(conn, settings): else: log.info("Rate limit Telegram alert suppressed (throttled)") + if _send_lock and release_send_lock is not None: + release_send_lock(conn) + return sent_count def step_check_status(conn, settings):