Maqolalar 20 daqiqa 195 ko‘rish
Botda rejalashtirilgan vazifalar: haftalik hisobot va anomaliya-alerting
Bot faqat savolga javob bermaydi — dushanba ertalab haftalik hisobotni o'zi yuboradi, g'ayrioddiy o'zgarishni o'zi sezib ogohlantiradi. Restart'ga chidamli scheduler, davr kalitlari va oddiy anomaliya mantiqiy — Humo botidan olingan pattern.
Mundarija
Avvalgi qismlarda bot so'ralganda javob berdi. Endi teskari yo'nalish: Humo botida adminlar dushanba ertalab haftalik tahlilni, oy boshida oylik hisobotni va kun davomida g'ayrioddiy o'zgarish bo'lsa — darhol ogohlantirishni so'ramasdan oladi. Buning tagida ikkita kichik, lekin puxta o'ylangan mexanizm bor: restart'ga chidamli rejalashtiruvchi va oddiy (AI'siz!) anomaliya mantiqiy.
Qaysi tool: cron, systemd timer yoki bot ichidagi loop?#
| Variant | Qachon mos |
|---|---|
| cron / systemd timer | Alohida skript sifatida ishlaydigan vazifa (backup, tozalash) — DevOps seriyasida ko'ramiz |
| Celery beat | Navbat allaqachon bor bo'lgan katta tizim — Celery haqida |
| Bot ichidagi asyncio loop | Bot doim ishlab turgan jarayon; vazifa bot konteksti (bot obyekti, adminlar ro'yxati) bilan ishlashi kerak |
Bot allaqachon 24/7 jarayon — unga yana bitta asyncio vazifa qo'shish eng arzon va eng sodda yo'l. Lekin ikkita tuzoqni hal qilish shart: restart (bot har deploy'da qayta ko'tariladi — hisobot ikki marta ketmasin yoki umuman o'tib ketmasin) va xato (bitta yiqilgan vazifa butun scheduler'ni o'ldirmasin).
1-qadam: davr kalitlari — restart'ga chidamlilikning siri#
Yechim g'oyasi: har vazifa o'z davri uchun bitta kalit oladi — haftalik
hisobot uchun "2026-W33", oylik uchun "2026-07", kunlik tekshiruv uchun
"2026-08-16". Yuborilgan kalitlar faylda saqlanadi. Restart bo'ldimi, o'chib
yondimi — kalit faylda bor ekan, vazifa qayta yuborilmaydi; kalit yo'q
bo'lsa — keyingi tekshiruvda yuboriladi. Cron'dagi "o'sha daqiqada o'chiq
bo'lsang, o'tib ketdi" muammosi ham yo'qoladi — vazifa o'z kuni ichida,
kechikib bo'lsa ham, baribir ketadi (bu chegara pastdagi testda ko'rinadi).
bot/
├── scheduler/
│ ├── __init__.py
│ ├── periods.py ← davr hisob-kitobi (sof funksiyalar — testlanadi)
│ ├── state.py ← yuborilgan kalitlar fayli
│ └── runner.py ← asyncio loop
├── reports/
│ ├── notify.py ← adminlarga yuborish
│ ├── weekly.py ← haftalik va oylik hisobot
│ └── anomaly.py ← anomaliya mantiqiy
├── api/
│ └── stats_api.py ← ko'rsatkichlar manbai (kompaniya API'si)
└── app.py
# bot/scheduler/periods.py
from datetime import datetime, timedelta, timezone
# Toshkent: UTC+5, DST yo'q — shuning uchun oddiy timedelta bilan xavfsiz
TZ = timezone(timedelta(hours=5))
WEEKLY_HOUR = 9 # dushanba 09:00
MONTHLY_DAY = 1 # oyning 1-kuni
ANOMALY_HOUR = 20 # har kuni 20:00 da kunlik tekshiruv
def week_key(now: datetime) -> str:
"""O'tgan TO'LIQ hafta kaliti: '2026-W33'."""
last_sunday = now - timedelta(days=now.isoweekday())
year, week, _ = last_sunday.isocalendar()
return f"{year}-W{week:02d}"
def month_key(now: datetime) -> str:
"""O'tgan oy kaliti: '2026-07'."""
first_of_month = now.replace(day=1)
prev = first_of_month - timedelta(days=1)
return f"{prev.year}-{prev.month:02d}"
def due_task(now: datetime, sent: set[str]) -> tuple[str, str] | None:
"""Navbatdagi BITTA vazifa: (tur, davr_kaliti) yoki None.
Bitta chaqiruvda bitta vazifa — ikkitasi to'planib qolgan bo'lsa
(masalan bot bir hafta o'chiq turdi), navbatma-navbat yuboriladi,
adminlar oqimi bosilmaydi.
"""
if now.isoweekday() == 1 and now.hour >= WEEKLY_HOUR:
key = f"weekly:{week_key(now)}"
if key not in sent:
return ("weekly", key)
if now.day == MONTHLY_DAY and now.hour >= WEEKLY_HOUR:
key = f"monthly:{month_key(now)}"
if key not in sent:
return ("monthly", key)
if now.hour >= ANOMALY_HOUR:
key = f"anomaly:{now.date().isoformat()}"
if key not in sent:
return ("anomaly", key)
return None
Nega >= — aynan soatga tenglik emas
now.hour == 9 sharti bot 09:00 da bir daqiqa o'chiq bo'lsa vazifani
yo'qotadi. >= bilan esa "kechiksa ham yuboradi": 09:00 da o'chiq edi —
11:30 da ko'tarildi — hisobot 11:30 da ketadi. Davr kaliti takrorlanishdan
saqlaydi, >= o'tkazib yuborishdan.
2-qadam: holat fayli#
# bot/scheduler/state.py
import json
import logging
import os
from pathlib import Path
log = logging.getLogger(__name__)
STATE_FILE = Path("var/scheduler_state.json")
KEEP_LAST = 200 # fayl cheksiz o'smasin
def _load_keys() -> list[str]:
if not STATE_FILE.exists():
return []
try:
return json.loads(STATE_FILE.read_text())
except json.JSONDecodeError:
# Buzilgan fayl scheduler'ni butunlay to'xtatib qo'ymasin: ogohlantirib,
# bo'sh ro'yxatdan boshlaymiz. Eng yomon holatda joriy davr hisoboti
# bir marta qayta ketadi — hech qachon ketmaganidan yaxshi
log.warning("scheduler holat fayli buzilgan, qaytadan boshlanadi")
return []
def load_sent() -> set[str]:
return set(_load_keys())
def mark_sent(key: str) -> None:
keys = _load_keys()
if key not in keys:
keys.append(key)
STATE_FILE.parent.mkdir(exist_ok=True)
tmp = STATE_FILE.with_suffix(".tmp")
# oxirgi 200 kalit — QO'SHILGAN tartibda. sorted() bilan kesilsa, "anomaly:"
# alifboda "weekly:" dan oldin turgani uchun yangi kalitlar o'chib ketardi
tmp.write_text(json.dumps(keys[-KEEP_LAST:], ensure_ascii=False))
# atomar almashtirish: yozish o'rtasida tok o'chsa ham eski fayl butun qoladi
os.replace(tmp, STATE_FILE)
Faylda uchta qaror bor, har biri real xatodan saqlaydi:
-
qo'shilgan tartibda kesish. Oddiy
sorted(sent)[-200:]alifbo bo'yicha kesadi, alifboda esaanomaly:doimmonthly:vaweekly:dan oldin turadi. Haftalik va oylik kalitlar 200 taga yetganda (taxminan uch yilda) bugungi anomaliya kaliti yozilgan zahoti kesilib ketadi va vazifa har daqiqada qayta yuboriladi. To'rt yillik simulyatsiyada aynan shunday bo'ldi: 2029-yil sentabridan boshlab kunlik ogohlantirish har tekshiruvda takrorlandi. Kalitlar ketma-ket qo'shilgani uchun ro'yxat oxiri — doim eng yangilari; -
atomar yozish. Fayl avval
.tmpga yoziladi, keyinos.replacebilan almashtiriladi — yozish o'rtasida tok o'chsa ham eski fayl butun qoladi; -
buzilgan fayl scheduler'ni o'ldirmaydi. Oddiy
json.loadsbuzuq faylda har daqiqada xato beradi va bitta ham vazifa bajarilmaydi — sinovda shunday bo'ldi. Endi ogohlantirish yoziladi va hisob noldan boshlanadi.
3-qadam: anomaliya — AI emas, arifmetika#
"Anomaliya" so'zi qo'rqinchli eshitiladi, lekin ko'p hollarda oddiy taqqoslash yetadi: bugungi ko'rsatkich o'tgan ikki haftaning shu kunlaridagi o'rtachasidan keskin farq qilyaptimi?
# bot/reports/anomaly.py
from dataclasses import dataclass
from aiogram import Bot
from bot.api import stats_api
from bot.reports.notify import notify_admins
THRESHOLD = 0.5 # ±50% dan katta og'ish — e'tiborga loyiq
@dataclass(frozen=True)
class Anomaly:
metric: str
today: float
baseline: float
@property
def change_pct(self) -> int:
return round((self.today - self.baseline) / self.baseline * 100)
def detect(metrics: dict[str, float], baselines: dict[str, float]) -> list[Anomaly]:
"""metrics: bugungi qiymatlar; baselines: o'tgan davr o'rtachasi."""
found = []
for name, today in metrics.items():
base = baselines.get(name)
if not base: # tarix yo'q — taqqoslab bo'lmaydi
continue
if abs(today - base) / base > THRESHOLD:
found.append(Anomaly(name, today, base))
return found
async def run_daily_check(bot: Bot) -> None:
metrics, baselines = await stats_api.today_and_baseline()
found = detect(metrics, baselines)
if not found:
return # bot jim = hammasi joyida
lines = [
f"— {a.metric}: {a.today:g} (odatda {a.baseline:g}, {a.change_pct:+d}%)" for a in found
]
await notify_admins(bot, "⚠️ Bugungi anomaliyalar:\n" + "\n".join(lines))
detect — sof funksiya, 15 qator. U Humo'da "bugun so'rovlar soni odatdagidan
70% kam — API'da muammo yo'qmi?" degan xabarni beradi. AI bu yerda shart emas — chegara
aniq, mantiq tekshiriladigan. (AI'ning o'rni — topilgan anomaliyaga izoh
yozish; buni keyingi qismda ko'ramiz.)
Avval deterministik, keyin aqlli
Alerting'da eng muhimi — ishonch: "bot jim = hammasi joyida" ishonchi. Deterministik mantiqni tekshirish oson, LLM'nikini — yo'q. Shuning uchun signal chiqarishni formulaga, signalni chiroyli tushuntirishni esa AI'ga bering — 1-qismdagi ajratish printsipi bu yerda ham ishlaydi.
Adminlarga yuborish#
Hisobot va ogohlantirishlar bir nechta adminga ketadi. Ma'lumot manbai — kompaniya API'si, bot uchun undan uchta funksiya kerak:
# bot/api/stats_api.py — interfeys (ichi kompaniya API'siga so'rov yuboradi)
async def weekly_summary() -> str:
"""O'tgan to'liq haftaning tayyor hisobot matni."""
async def monthly_summary() -> str:
"""O'tgan oyning tayyor hisobot matni."""
async def today_and_baseline() -> tuple[dict[str, float], dict[str, float]]:
"""Bugungi ko'rsatkichlar va o'tgan ikki haftaning shu kunlaridagi o'rtachasi."""
# bot/reports/weekly.py
from aiogram import Bot
from bot.api import stats_api
from bot.reports.notify import notify_admins
async def send_weekly_report(bot: Bot) -> None:
await notify_admins(bot, await stats_api.weekly_summary())
async def send_monthly_report(bot: Bot) -> None:
await notify_admins(bot, await stats_api.monthly_summary())
Yuborishning o'zi — bitta umumiy funksiya:
# bot/reports/notify.py
import logging
import os
from aiogram import Bot
from aiogram.exceptions import TelegramAPIError
log = logging.getLogger(__name__)
# .env: ADMIN_IDS=111111111,222222222
ADMIN_IDS = [int(x) for x in os.environ.get("ADMIN_IDS", "").split(",") if x.strip()]
async def notify_admins(bot: Bot, text: str) -> None:
delivered = 0
for admin_id in ADMIN_IDS:
try:
await bot.send_message(admin_id, text)
delivered += 1
except TelegramAPIError:
# bitta admin botni bloklagan bo'lsa ham qolganlar xabarni olsin
log.exception("admin %s ga yuborilmadi", admin_id)
if delivered == 0:
# hech kimga yetmadi (masalan, tarmoq yo'q) — vazifa muvaffaqiyatsiz:
# runner kalitni yozmaydi va keyingi daqiqada qayta urinadi
raise RuntimeError("xabar hech bir adminga yetib bormadi")
Har admin uchun alohida try bejiz emas. Oddiy
for admin_id in ADMIN_IDS: await bot.send_message(...) siklida bitta admin
botni bloklasa, send_message xato beradi, vazifa yiqiladi va runner
kalitni yozmaydi. Keyingi daqiqada hammasi boshidan qaytariladi — ro'yxatda
bloklagan admindan oldin turgan admin esa har daqiqada xuddi shu xabarni
qayta oladi. Sinovda bunday sikl yarim soniya ichida birinchi adminga 24 ta
bir xil xabar yubordi. Endi bloklangan admin shunchaki logga yoziladi,
qolganlar xabarni bir marta oladi. Xabar hech kimga yetmasa (masalan,
tarmoq yo'q), funksiya xato beradi va vazifa keyinroq qayta urinadi.
4-qadam: runner — o'lmaydigan loop#
# bot/scheduler/runner.py
import asyncio
import logging
from datetime import datetime
from bot.reports import anomaly, weekly
from bot.scheduler.periods import TZ, due_task
from bot.scheduler.state import load_sent, mark_sent
log = logging.getLogger(__name__)
CHECK_EVERY = 60 # soniya
TASKS = {
"weekly": weekly.send_weekly_report,
"monthly": weekly.send_monthly_report,
"anomaly": anomaly.run_daily_check,
}
async def scheduler_loop(bot):
while True:
try:
now = datetime.now(TZ)
task = due_task(now, load_sent())
if task is not None:
kind, key = task
await TASKS[kind](bot) # vazifaning o'zi
mark_sent(key) # MUVAFFAQIYATDAN KEYIN belgilanadi
log.info("scheduler: %s yuborildi", key)
except Exception:
# bitta xato loop'ni o'ldirmasin: log yozamiz, keyingi aylanishda
# qayta urinamiz (kalit belgilanmagan — vazifa yo'qolmaydi)
log.exception("scheduler xatosi")
await asyncio.sleep(CHECK_EVERY)
Scheduler 2-qismdagi app.py ga ulanadi:
import'larga from bot.scheduler.runner import scheduler_loop qo'shiladi,
main() esa shunday bo'ladi:
# bot/app.py
async def main() -> None:
bot = Bot(os.environ["BOT_TOKEN"])
# scheduler bot polling bilan YONMA-YON yashaydi. Havolani o'zgaruvchida
# saqlaymiz: event loop task'larga faqat zaif havola tutadi, hech kim
# ushlamagan task ish o'rtasida yig'ishtirib yuborilishi mumkin
scheduler = asyncio.create_task(scheduler_loop(bot))
try:
await dp.start_polling(bot)
finally:
scheduler.cancel()
Ikki nozik qaror izohlansin:
-
mark_sent(key)vazifa muvaffaqiyatli o'tgandan keyin chaqiriladi: yuborish yiqilsa, kalit yozilmaydi va keyingi daqiqada qayta uriniladi. Teskari tartibda hisobot "yuborildi deb hisoblanib", aslida yo'qolardi; -
try/exceptbutun aylanani o'raydi: scheduler — uzun umrli jarayon, undagi istisno "bir martalik xato" emas, "bugungi hisobotlar o'ldi" degani; -
create_tasknatijasischedulero'zgaruvchisida saqlanadi. Python hujjati bu haqda alohida ogohlantiradi: event loop task'larga faqat zaif havola tutadi, hech kim ushlamagan task esa istalgan payt yig'ishtirib yuborilishi mumkin — scheduler jimgina yo'qolib qolardi.
5-qadam: test — vaqtni qo'lda berish#
due_task sof funksiya — datetime'ni qo'lda berib, har stsenariyni tekshiramiz:
# tests/test_periods.py
from datetime import datetime
from bot.scheduler.periods import TZ, due_task
def dt(day, hour):
return datetime(2026, 8, day, hour, 0, tzinfo=TZ) # 2026-08-17 — dushanba
def test_weekly_fires_monday_after_nine():
assert due_task(dt(17, 9), set()) == ("weekly", "weekly:2026-W33")
def test_weekly_not_resent_same_week():
sent = {"weekly:2026-W33"}
# dushanba kuni hisobot ketgan; endi navbat kunlik tekshiruvda (20:00 dan keyin)
assert due_task(dt(17, 21), sent) == ("anomaly", "anomaly:2026-08-17")
def test_quiet_before_hours():
assert due_task(dt(18, 8), {"weekly:2026-W33"}) is None
def test_missed_monday_is_skipped_on_tuesday():
# bot dushanba butun kun o'chiq edi — seshanba ertalab baribir hafta kaliti yo'q...
# lekin due_task faqat dushanba tekshiradi: bu qat'iy qaror — hafta hisobot
# 24 soatdan ortiq kechiksa, keyingi haftani kutgan ma'qul (eskirgan tahlil
# foydali emas). O'zingizga moslang: shartga `<= 2` kun qo'shsangiz ham bo'ladi
assert due_task(dt(18, 9), set()) is None
Holat faylining ikki xavfli holati ham test bilan mustahkamlanadi:
# tests/test_state.py
import bot.scheduler.state as state
def test_newest_keys_survive_trimming(tmp_path, monkeypatch):
monkeypatch.setattr(state, "STATE_FILE", tmp_path / "state.json")
monkeypatch.setattr(state, "KEEP_LAST", 3)
for key in ["weekly:2026-W30", "weekly:2026-W31", "weekly:2026-W32", "anomaly:2026-08-17"]:
state.mark_sent(key)
# alifbo bo'yicha "anomaly:" birinchi turadi — lekin eng yangi kalit saqlanishi shart
assert state.load_sent() == {"weekly:2026-W31", "weekly:2026-W32", "anomaly:2026-08-17"}
def test_corrupted_file_does_not_stop_scheduler(tmp_path, monkeypatch):
broken = tmp_path / "state.json"
broken.write_text('["weekly:2026-W3')
monkeypatch.setattr(state, "STATE_FILE", broken)
assert state.load_sent() == set()
uv run python -m pytest tests -q
Xulosa#
-
Bot ichidagi asyncio loop — doim ishlab turgan jarayon uchun eng sodda scheduler; cron va systemd timer alohida skriptlar dunyosi;
-
Restart'ga chidamlilik — davr kalitlari bilan:
weekly:2026-W33faylda bor ekan, hisobot takrorlanmaydi; yo'q ekan — o'z kuni ichida kechikib bo'lsa ham yuboriladi. Fayl atomar yoziladi va eng yangi kalitlarni saqlaydi; -
Bir nechta adminga yuborishda har biri alohida: bitta bloklangan admin qolganlarni takroriy xabar bilan ko'mmasin;
-
mark_sentfaqat muvaffaqiyatdan keyin — xato vazifani yo'qotmaydi; -
Anomaliya uchun AI kerak emas — o'rtachadan og'ish formulasi yetadi, u tekshiriladi va unga ishoniladi;
-
Sof
due_taskfunksiyasi — scheduler'ning butun aqli, va u to'liq testlanadi.
Seriya yakunida eng qizig'i: AI agent'ni botga integratsiya qilish — adminlar bazaga erkin savol beradi, agent SQL bilan javob topadi, suhbat tarixi va zaxira model bilan.