Mundarija (29)
- Bu darsda
- 1. Nega bu kerak?
- 2. Tuzilma
- 2.1 Qismlar
- 2.2 Uch qoida
- 3. Sxema: sql/202
- 3.1 Jadvallar
- 3.2 Dedup kaliti
- 3.3 Havola — faqat ichki yo'l
- 3.4 Kanal manzillari
- 4. Kanal tanlash
- 5. Yetkazish: idempotent va limitli
- 5.1 Ikki worker bitta ish
- 5.2 Soatlik limit
- 5.3 Holatlar
- 5.4 Eskirgan manzil
- 5.5 Kanon tan olgan kamchilik
- 5.6 Navbatga tushmay qolganlar
- 6. In-app: qo'ng'iroqcha
- 7. Hujumchi nigohi
- 8. Ko'p uchraydigan xatolar
- 9. Mashqlar
- 1-mashq (oson): bo'shliqlarni to'ldiring
- 2-mashq (o'rta): jim soatlar
- 3-mashq (qiyin): soatlik limit
- 10. Loyiha qadami: markaz.ts va sql/202
- 11. Real ishda
- Xulosa
- Manbalar
Bildirishnoma tizimi arxitekturasi: kanallar, dedup va limit
Qisqacha: Har modul o'zi email yoki SMS yuborsa, tez orada tartibsizlik boshlanadi: takror xatlar, o'chirib qo'yilgan kanalga yuborish, limitsiz SMS. To'g'ri yo'l — bitta bildirishnoma markazi:
bildir(foydalanuvchi, hodisa)bazaga ilova ichidagi (in-app) yozuvni va har kanal uchunyetkazishlarqatorini bitta tranzaksiyada yozadi, yuborishni esa navbatdagi worker qiladi. Kanal foydalanuvchi afzalligi va manzil bor-yo'qligiga qarab tanlanadi,dedup_kalittakrorni to'xtatadi, soatlik limit spamni,FOR UPDATEesa ikki marta yuborishni.
Bu darsda
- Bildirishnoma tizimining qismlarini (hodisa, kanal tanlash, yetkazish, in-app) chizasiz.
sql/202sxemasini o'qiysiz:bildirishnomalar,yetkazishlar, kanal manzillari.- Dedup kaliti, soatlik limit va idempotent yetkazishni SQL va kodda ko'rasiz.
- Kanal tanlash qoidasini o'zingiz yozasiz.
- Kanon
markaz.tsmodulini va uning 7 testini o'z Postgres'ingizda ishga tushirasiz.
Oldin bilishingiz kerak: Tranzaksiya asoslari, Qulflar: row, table va advisory, JSONB, Keyset pagination, Email yuborish, SMS provayderlar, Push bildirishnomalar.
Oxirgi to'rt darsda uchta "yuboruvchi" yozdik: Pochta, EskizProvayder, WebPush. Keyingi darslarda to'rtinchisi — Telegram boti — keladi. Bugun ularni bitta tizimga yig'amiz.
1. Nega bu kerak?
"Non olish" vazifasining muddati yaqinlashdi. Kim, qayerga, nima yuborishi kerak?
- Aziz email va Telegram'ni yoqqan, push yo'q.
- Dilnoza email'ni o'chirgan, Telegram'ni ulamagan.
- Sardor hech narsani sozlamagan.
- Cron har 5 daqiqada muddatlarni tekshiradi — bir vazifa uchun ikki marta ishlab qolsa?
- Worker ikkita nusxada ishlaydi — ikkalasi bir xatni olsa?
- Telegram boti bloklangan — har safar xato olib turamizmi?
Agar har modul (vazifalar, izohlar, to'lovlar) o'zi yuborsa, bu savollarning har biriga har modulda alohida javob yoziladi — va bittasida unutiladi.
O'xshatish: kompaniyadagi devonxona. Har bo'lim o'zi pochtaga yugurmaydi. Hujjat devonxonaga beriladi, u ro'yxatga yozadi (raqam bilan — ikki marta yozilmaydi), har adresat qanday aloqani afzal ko'rishini biladi va kun oxirida jo'natadi. Kimga yetmagani ham daftarda.
2. Tuzilma
2.1 Qismlar
flowchart LR
H[Hodisa: muddat yaqin] --> B[bildir]
B --> DB[(bildirishnomalar + yetkazishlar)]
B -->|COMMIT dan keyin| N[Navbat]
N --> W[Worker: yetkaz]
W --> E[Email]
W --> T[Telegram]
W --> P[Push]
W --> S[SMS]
DB --> A[Ilova: qo'ng'iroqcha]Diagrammaga qarang: hodisa faqat bildir() ni chaqiradi va qaysi kanal ekanini bilmaydi. Markaz yozuvni bazaga qo'yadi. Bu yozuv ikki ishga xizmat qiladi:
- In-app — ilovadagi qo'ng'iroqcha belgisi va ro'yxat ("3 ta o'qilmagan").
- Yetkazish manbai — har kanal uchun alohida qator: email ketdimi, Telegram ketdimi.
Yuborishni esa boshqa jarayon — worker (ishchi) — qiladi. HTTP so'rov SMTP'ni kutib turmaydi.
2.2 Uch qoida
| Qoida | Nima uchun | Qayerda |
|---|---|---|
| Yozuv va kanallar — bitta tranzaksiyada | yarim yozilgan bildirishnoma bo'lmasin | bildir() |
| Navbatga — COMMIT'dan keyin | worker hali ko'rinmaydigan qatorni olmasin | bildir() oxiri |
| Yetkazish — idempotent | qayta ishlansa ham bir marta yuborilsin | yetkaz() |
3. Sxema: sql/202
3.1 Jadvallar
Kanon to'rtta jadval qo'shdi:
| Jadval | Nima saqlaydi |
|---|---|
bildirishnomalar |
sarlavha, matn, ichki havola, dedup_kalit, oqilgan_vaqt |
yetkazishlar |
(bildirishnoma, kanal) → holat, urinishlar, oxirgi xato |
telegram_ulanishlar |
foydalanuvchi → chat_id (bigint), faol |
push_obunalar |
endpoint (noyob, faqat https://), p256dh, auth |
Asosiy jadvalning cheklovlari:
CONSTRAINT bildirishnomalar_dedup_kalit_key
UNIQUE (foydalanuvchi_id, dedup_kalit),
CONSTRAINT bildirishnomalar_tur_check
CHECK (tur ~ '^[a-z]+(\.[a-z_]+)*$'),
CONSTRAINT bildirishnomalar_havola_check
CHECK (havola IS NULL OR havola ~ '^/[^/]')Afzalliklar uchun yangi jadval yo'q: ular 27-qismdagi foydalanuvchilar.sozlamalar JSONB ustunida — {"email": true, "telegram": false} (JSONB). Kalit yo'q bo'lsa — sukutdagi qiymat.
SQL misollarini biz o'z PostgreSQL 17.6 klasterimizda bajardik — kanon sql/001–204 (namuna ma'lumotlar bilan). Kanonning o'zi PostgreSQL 18.6 da; bu fayllar 17 da ham xatosiz qo'llandi.
SELECT id, toliq_ism, sozlamalar->'bildirishnoma' AS bildirishnoma
FROM foydalanuvchilar WHERE id IN (1, 2, 3, 6) ORDER BY id; id | toliq_ism | bildirishnoma
----+------------------+------------------------------------
1 | Aziz Karimov | {"email": true, "telegram": true}
2 | Dilnoza Rahimova | {"email": false}
3 | Sardor Tursunov |
6 | Gulnora Saidova | {"email": true, "telegram": false}
(4 rows)3.2 Dedup kaliti
Dedup (deduplication — takrorni olib tashlash) kerak bo'ladigan holat: cron har 5 daqiqada "muddati 1 soatdan kam vazifalar" ni topadi. 18:00 dagi vazifa 17:05 da ham, 17:10 da ham topiladi. Bildirishnoma bitta bo'lishi kerak. Yechim — hodisadan yasalgan kalit: vazifa:1:muddat:2026-10-28. Bir xil kalit ikkinchi marta kirmaydi:
INSERT INTO bildirishnomalar
(foydalanuvchi_id, tur, sarlavha, matn, havola, dedup_kalit)
VALUES (1, 'vazifa.muddat', 'Muddat yaqin', 'Non olish — 18:00',
'/vazifalar/1', 'vazifa:1:muddat:2026-10-28')
ON CONFLICT (foydalanuvchi_id, dedup_kalit) DO NOTHING
RETURNING dedup_kalit; dedup_kalit
----------------------------
vazifa:1:muddat:2026-10-28
(1 row)
INSERT 0 1Xuddi shu so'rov ikkinchi marta:
INSERT INTO bildirishnomalar
(foydalanuvchi_id, tur, sarlavha, matn, havola, dedup_kalit)
VALUES (1, 'vazifa.muddat', 'Muddat yaqin', 'Non olish — 18:00',
'/vazifalar/1', 'vazifa:1:muddat:2026-10-28')
ON CONFLICT (foydalanuvchi_id, dedup_kalit) DO NOTHING
RETURNING dedup_kalit; dedup_kalit
-------------
(0 rows)
INSERT 0 0ON CONFLICT … DO NOTHING — xato yo'q, qator ham yo'q, RETURNING bo'sh (UPSERT va MERGE). Kod bo'sh natijani ko'rib "bu allaqachon bor" deb tushunadi va kanallarga hech narsa qo'shmaydi. Kalitda sana bor: ertangi muddat — yangi kalit, yangi bildirishnoma. Kalit NULL bo'lsa (tizim xabari), UNIQUE uni cheklamaydi — Postgres'da NULL lar teng emas.
3.3 Havola — faqat ichki yo'l
Bildirishnoma ichidagi havola ilovada bosiladi. Unga tashqi manzil tushsa — ochiq yo'naltirish (open redirect, Dasturiy navigatsiya va URL holati darsidagi xavf): "Hisobingiz bloklandi" bildirishnomasi fishing saytga olib boradi. Sxema buni bazaning o'zida to'xtatadi:
\set VERBOSITY terse
INSERT INTO bildirishnomalar
(foydalanuvchi_id, tur, sarlavha, matn, havola)
VALUES (1, 'tizim', 'Yutuq!', 'Bosing',
'https://yomon.example/kirish');
INSERT INTO bildirishnomalar
(foydalanuvchi_id, tur, sarlavha, matn, havola)
VALUES (1, 'tizim', 'Yutuq!', 'Bosing', '//yomon.example/kirish');ERROR: new row for relation "bildirishnomalar" violates check constraint "bildirishnomalar_havola_check"
ERROR: new row for relation "bildirishnomalar" violates check constraint "bildirishnomalar_havola_check"Ikkinchisi qiziqroq: //yomon.example — "protokolsiz URL", brauzer uni boshqa domen deb ochadi. ^/[^/] qoidasi: bitta / bilan boshlansin, ikkinchi belgi / bo'lmasin. Ilova kodi ham tekshiradi, lekin bazadagi cheklov — oxirgi qatlam: qaysi kod yozmasin, yomon havola saqlanmaydi.
3.4 Kanal manzillari
Kanal tanlash uchun markaz bitta so'rovda afzallik va manzillarni oladi. Aziz Telegram'ni ulagan deb faraz qilamiz:
INSERT INTO telegram_ulanishlar (foydalanuvchi_id, chat_id)
VALUES (1, 900000001);
SELECT f.id,
f.sozlamalar->'bildirishnoma' AS sozlama,
EXISTS (SELECT 1 FROM telegram_ulanishlar t
WHERE t.foydalanuvchi_id = f.id AND t.faol)
AS telegram,
EXISTS (SELECT 1 FROM push_obunalar p
WHERE p.foydalanuvchi_id = f.id) AS push
FROM foydalanuvchilar f WHERE f.id IN (1, 2, 3) ORDER BY f.id;INSERT 0 1
id | sozlama | telegram | push
----+-----------------------------------+----------+------
1 | {"email": true, "telegram": true} | t | f
2 | {"email": false} | f | f
3 | | f | f
(3 rows)chat_id — bigint: Telegram ID'lari 52 bitgacha bo'lishi mumkin, int (32 bit) ga sig'maydi. Bot bloklansa (403) — faol = false, ulanish o'chmaydi: foydalanuvchi botni qayta ochsa, tiklash oson.
4. Kanal tanlash
Qoida uch qavat: afzallik (foydalanuvchi yoqqanmi), sukut (sozlamagan bo'lsa), manzil (kanalga yuborish mumkinmi). Kanon mantig'ining JS nusxasi:
const SUKUT = { email: true, telegram: true, push: true, sms: false };
const KANALLAR = ["email", "telegram", "push", "sms"];
function kanallarniTanla(sozlama, manzil, soralgan = KANALLAR) {
const yoqilgan = (k) =>
typeof sozlama?.[k] === "boolean" ? sozlama[k] : SUKUT[k];
return soralgan.filter((k) => yoqilgan(k) && manzil[k]);
}
const hamma = { email: true, telegram: true, push: true, sms: true };
console.log(kanallarniTanla({ email: true, telegram: true }, hamma));
const tgsiz = { ...hamma, telegram: false };
console.log(kanallarniTanla({ email: false }, tgsiz));
console.log(kanallarniTanla(null, { ...hamma, push: false }));
console.log(kanallarniTanla({ sms: true }, hamma, ["sms"]));Konsolda:
[ 'email', 'telegram', 'push' ]
[ 'push' ]
[ 'email', 'telegram' ]
[ 'sms' ]- Birinchi: push sozlanmagan, sukut
true— kiradi. - Ikkinchi: email o'chirilgan, Telegram manzili yo'q — faqat push.
- Uchinchi: sozlama
null— sukutlar; SMS sukutda yo'q. - To'rtinchi: SMS faqat aniq yoqilganda va so'ralganda.
SMS sukutda o'chiq — u pullik. Kanon sxemasida telefon raqami hali yo'q (28-qism qo'shadi), shuning uchun modulda SMS manzili hozircha doim false.
typeof … === "boolean" — nega ?? SUKUT[k] emas? ?? faqat null/undefined ni almashtiradi. JSONB'ga xato bilan "email": "yo'q" satri tushsa, ?? uni "yoqilgan" (truthy) deb o'tkazardi. Turni tekshirish xavfsizroq.
Tekshirib ko'ring: Parolni tiklash xatini ham shu markaz orqali yuborish kerakmi? Foydalanuvchi email bildirishnomalarini o'chirgan bo'lsa-chi?
Javob
Parol tiklash — bildirishnoma emas, xavfsizlik xati: foydalanuvchi uni o'zi so'radi va email afzalligidan qat'i nazar olishi kerak. Uni markazning afzallik filtridan o'tkazmasdan to'g'ridan Pochta bilan (28-qismda) yuborish to'g'ri. Markaz — "eslatma, izoh, yangilik" kabi xabarlar uchun, foydalanuvchi o'chira oladigan narsalar uchun.
5. Yetkazish: idempotent va limitli
5.1 Ikki worker bitta ish
Navbat "kamida bir marta" (at-least-once) yetkazadi: worker ishni olib, yuborib, tasdiqlashdan oldin o'lsa, ish boshqa worker'ga qayta beriladi. Natija — ikki xat. Bundan tashqari, ikki worker bir xil ishni bir vaqtda olishi mumkin.
Kanon yetkaz() qatorni SELECT … FOR UPDATE OF y bilan qulflaydi (Qulflar). Ikkinchi worker shu qatorga kelganda birinchisi tugaguncha kutadi, keyin holat = 'yuborildi' ni ko'radi va "allaqachon" qaytaradi. Kanon testi: ikki parallel yetkaz → natijalar ["allaqachon", "yuborildi"], yuboruvchi bir marta chaqirilgan.
sequenceDiagram
participant W1 as Worker 1
participant DB as Postgres
participant W2 as Worker 2
participant T as Yuboruvchi (SMTP)
W1->>DB: BEGIN, SELECT … FOR UPDATE (qatorni qulfladi)
W2->>DB: BEGIN, SELECT … FOR UPDATE
Note over W2,DB: kutadi — qator band
W1->>T: xatni yuboradi
T-->>W1: OK
W1->>DB: holat = yuborildi, COMMIT
DB-->>W2: qator bo'shadi, holat = yuborildi
W2->>DB: COMMIT, natija "allaqachon"Diagrammaga qarang: ikkinchi worker xato bermaydi, faqat kutadi. Qulf bo'shagach u yangilangan holatni ko'radi va hech narsa yubormaydi.
5.2 Soatlik limit
| Kanal | Soatiga (bitta foydalanuvchiga) |
|---|---|
| 10 | |
| telegram | 30 |
| push | 30 |
| sms | 3 |
Limitdan oshgani yuborilmaydi — otkazildi holati bilan qoladi. Hisoblash — oxirgi bir soatda yuborildi qatorlar soni. sql/202 dagi qisman indeks aynan shu so'rov uchun: ON yetkazishlar (kanal, yangilangan_vaqt) WHERE holat = 'yuborildi'.
Bu bitta foydalanuvchi uchun himoya: bug tufayli sikl 1 000 ta bildirishnoma yaratsa ham, Aziz soatiga 3 tadan ortiq SMS olmaydi va sizning hisobingiz bo'shamaydi.
5.3 Holatlar
stateDiagram-v2
[*] --> navbatda
navbatda --> yuborildi: yuboruvchi OK
navbatda --> navbatda: qayta (429, 5xx)
navbatda --> xato: doimiy xato yoki eskirgan manzil
navbatda --> otkazildi: soatlik limit
yuborildi --> [*]Holatlar kanon CHECK cheklovidan: navbatda, yuborildi, xato, otkazildi. Har kanal mustaqil: email yuborildi, Telegram xato bo'lishi mumkin — ma'mur panelda aniq ko'rinadi. Noto'g'ri kanal nomi bazaga kirmaydi:
\set VERBOSITY terse
INSERT INTO yetkazishlar (bildirishnoma_id, kanal)
SELECT id, 'whatsapp' FROM bildirishnomalar LIMIT 1;ERROR: new row for relation "yetkazishlar" violates check constraint "yetkazishlar_kanal_check"5.4 Eskirgan manzil
Yuboruvchi eskirgan desa (Telegram 403 — bot bloklangan, push 410), markaz manzilni o'zi tozalaydi: Telegram ulanishi faol = false, push obunasi o'chiriladi. Keyingi bildirishnomada bu kanal tanlanmaydi — kanon testi buni tekshiradi.
5.5 Kanon tan olgan kamchilik
yetkaz() tashqi so'rovni (SMTP, Telegram API) tranzaksiya ichida, qulf ushlab turib qiladi. Afzalligi — takror yuborish imkonsiz. Narxi — yuborish davomida bitta baza ulanishi band. Hovuzda (pool) 10 ulanish bo'lsa va yuboruvchi timeout'i 10 s bo'lsa, eng yomon holatda 10 ta osilgan yuborish butun hovuzni egallaydi (Connection pooling).
Muqobil — "claim" naqshi: qisqa tranzaksiyada holatni yuborilmoqda ga o'zgartirib, darhol COMMIT; yuborish — tranzaksiyasiz; natija — ikkinchi qisqa tranzaksiyada. Bunda ulanish band bo'lmaydi, lekin jarayon yuborishdan keyin o'lsa, "yuborilmoqda" da qolgan ishni qayta tiklash mantiqi kerak. Kanon soddalikni tanladi va bu kamchilikni ochiq yozdi.
5.6 Navbatga tushmay qolganlar
bildir() avval COMMIT qiladi, keyin navbatga qo'shadi. Ikkalasi orasida jarayon o'lsa — bazada navbatda qatori bor, navbatda esa ish yo'q. Kanon qolibKetganlar(300) — 5 daqiqadan eski navbatda qatorlarni topadi; rejali ish ularni qayta navbatga qo'yadi. Ya'ni yetkazishlar jadvalining o'zi outbox vazifasini bajaradi — bu naqsh Transactional outbox darsida to'liq.
6. In-app: qo'ng'iroqcha
Bildirishnomalar ro'yxati ilovada ham ko'rinadi. Kanon uchta funksiya berdi:
oqilmaganSoni(fId)— qo'ng'iroqchadagi raqam; qisman indeksWHERE oqilgan_vaqt IS NULLbilan tez.royxat(fId, limit, oldin)— keyset sahifalash ((yaratilgan_vaqt, id) < ($2, $3)) — Keyset pagination.oqildi(fId, idlar | "hammasi")— o'qilgan deb belgilash; qayta chaqiruv0qaytaradi.
Har so'rovda foydalanuvchi_id sharti bor, ustiga sxemada RLS (Row Level Security — 27-qism) — ilova roli faqat o'z qatorlarini ko'radi. yetkazishlar ni esa ilova roli faqat o'qiydi: unga faqat worker yozadi.
Real vaqtda yangi bildirishnomani ko'rsatish — Server-Sent Events darsida.
7. Hujumchi nigohi
| Hujum | Natijasi | Himoya |
|---|---|---|
| bildirishnomaga tashqi havola | fishing yo'naltirish | CHECK (havola ~ '^/[^/]') |
| boshqa foydalanuvchining ro'yxatini so'rash | ma'lumot sizishi | foydalanuvchi_id sharti + RLS |
| "izoh yozish" orqali 1 000 ta bildirishnoma | spam, SMS xarajati | soatlik limit, SMS sukutda o'chiq |
| matnda HTML | XSS ilovada yoki emailda | matn sifatida chiqarish, ekranlash |
Uchinchi qator: hujumchi Azizning vazifasiga 1 000 ta izoh yozadi, har izoh — bildirishnoma. Limitsiz tizim Azizning telefonini va sizning SMS hisobingizni portlatadi. Limit kanal bo'yicha, foydalanuvchi bo'yicha — eng arzon himoya.
8. Ko'p uchraydigan xatolar
| Xato | Natija | To'g'risi |
|---|---|---|
| har modul o'zi yuboradi | afzalliklar va limitlar har joyda boshqacha | bitta markaz |
| HTTP so'rov ichida SMTP'ni kutish | sekin javob, timeout | navbat + worker |
| navbatga COMMIT'dan oldin qo'yish | worker qatorni topmaydi | COMMIT'dan keyin |
| dedup kalitsiz cron | takror bildirishnomalar | UNIQUE (foydalanuvchi_id, dedup_kalit) |
yuborildi ni tekshirmasdan yuborish |
ikki xat | FOR UPDATE + holat tekshiruvi |
| bloklangan botga yuborishda davom etish | doimiy xatolar | faol = false |
chat_id int |
katta ID sig'maydi | bigint |
9. Mashqlar
Mashqlar kurs/mashqlar/29/18-bildirishnoma/ da.
1-mashq (oson): bo'shliqlarni to'ldiring
- Takroriy bildirishnomani to'xtatuvchi ustun —
- Ikki worker bir qatorni bir vaqtda yubormasligi uchun SQL —
SELECT … FOR - Limitdan oshgan yetkazish holati —
- Bot bloklanganda
telegram_ulanishlardagi o'zgaradigan ustun — - Sukutda o'chiq kanal —
Yechim
- dedup_kalit —
UNIQUE (foydalanuvchi_id, dedup_kalit). - UPDATE —
FOR UPDATE OF y: faqatyetkazishlarqatori qulflanadi. - otkazildi — xato emas, ataylab yuborilmadi.
- faol —
false; ulanish o'chirilmaydi. - sms — pullik, faqat aniq yoqilsa.
2-mashq (o'rta): jim soatlar
Foydalanuvchi "22:00 dan 08:00 gacha bezovta qilmang" deydi. jimSoatmi(soat, daqiqa, jim) yozing: jim = { boshi: "22:00", oxiri: "08:00" }. Oraliq yarim tundan o'tishi ham, o'tmasligi ham mumkin (13:00–14:00). boshi — kiradi, oxiri — kirmaydi. Ishora: vaqtni "kun boshidan daqiqalar"ga aylantiring; boshi > oxiri — yarim tundan o'tgan oraliq.
Yechim
function jimSoatmi(soat, daqiqa, jim) {
const hozir = soat * 60 + daqiqa;
const [bs, bd] = jim.boshi.split(":").map(Number);
const [os, od] = jim.oxiri.split(":").map(Number);
const boshi = bs * 60 + bd;
const oxiri = os * 60 + od;
if (boshi <= oxiri) return hozir >= boshi && hozir < oxiri;
return hozir >= boshi || hozir < oxiri;
}
const tun = { boshi: "22:00", oxiri: "08:00" };
const tush = { boshi: "13:00", oxiri: "14:00" };
console.log(jimSoatmi(23, 30, tun), jimSoatmi(7, 59, tun));
console.log(jimSoatmi(8, 0, tun), jimSoatmi(12, 0, tun));
console.log(jimSoatmi(13, 30, tush), jimSoatmi(14, 0, tush));Konsolda:
true true
false false
true falseYarim tundan o'tgan oraliqda shart yoki bilan: 23:30 — "22:00 dan keyin", 07:59 — "08:00 dan oldin". Oddiy oraliqda va bilan. Bu mantiq kanonda yo'q — kengaytma. Jim soatda bildirishnoma o'chmaydi: in-app yozuv qoladi, push va SMS esa ertalabgacha kechiktiriladi (navbatda kechiktirilgan ish — BullMQ asoslari). Soat foydalanuvchining vaqt mintaqasida hisoblanadi.
3-mashq (qiyin): soatlik limit
yubor(tarix, kanal, hozir, limit) yozing: tarix — { kanal, vaqt } yozuvlar massivi (vaqt — millisekund). Oxirgi bir soatda shu kanal bo'yicha limit ta yoki ko'p yozuv bo'lsa — "otkazildi", aks holda yozuv qo'shilib "yuborildi". Oltita yuborishni sinang: SMS limiti 3, to'rtinchi SMS — o'tkaziladi, email — boshqa kanal, 61 daqiqadan keyingi SMS — yana ruxsat. Ishora: filter(…).length (filter).
Yechim
const SOAT = 60 * 60 * 1000;
function limitTekshir(tarix, kanal, hozir, limit) {
const soni = tarix.filter(
(y) => y.kanal === kanal && y.vaqt > hozir - SOAT,
).length;
return soni < limit;
}
function yubor(tarix, kanal, hozir, limit) {
if (!limitTekshir(tarix, kanal, hozir, limit)) return "otkazildi";
tarix.push({ kanal, vaqt: hozir });
return "yuborildi";
}
const tarix = [];
const t0 = Date.UTC(2026, 9, 28, 9, 0);
const daq = 60 * 1000;
console.log(yubor(tarix, "sms", t0, 3));
console.log(yubor(tarix, "sms", t0 + 10 * daq, 3));
console.log(yubor(tarix, "sms", t0 + 20 * daq, 3));
console.log(yubor(tarix, "sms", t0 + 30 * daq, 3));
console.log(yubor(tarix, "email", t0 + 30 * daq, 3));
console.log(yubor(tarix, "sms", t0 + 61 * daq, 3));
console.log(tarix.length);Konsolda:
yuborildi
yuborildi
yuborildi
otkazildi
yuborildi
yuborildi
561-daqiqada birinchi SMS (09:00) "bir soat ichida" dan chiqdi — oyna siljidi (sliding window). otkazildi tarixga yozilmaydi: aks holda o'tkazilganlar ham limitni yeb, oyna hech qachon bo'shamasdi. Kanon SQL'da xuddi shunday: faqat holat = 'yuborildi' sanaladi. Bitta jarayon xotirasidagi massiv ko'p nusxali serverda ishlamaydi — shuning uchun kanonda hisob bazada (Rate limiting — Redis varianti).
10. Loyiha qadami: markaz.ts va sql/202
Kanon modul — modullar/src/bildirishnoma/markaz.ts. U Postgres'ga (pg Pool) va Navbat interfeysiga tayanadi; navbatning o'zi (BullMQ) BullMQ asoslari darsida ulanadi. Turlar:
export type Kanal = "email" | "telegram" | "push" | "sms";
export type YetkazishIshi = { bildirishnomaId: number; kanal: Kanal };
export interface Navbat {
qosh(ishlar: YetkazishIshi[]): Promise<void>;
}
export type YuborishNatija =
| { holat: "yuborildi" }
| { holat: "eskirgan"; pushId?: number }
| { holat: "qayta"; sabab: string }
| { holat: "xato"; sabab: string };
export type Yuboruvchi =
(q: Qabul, x: Xabar) => Promise<YuborishNatija>;Yuboruvchi — oddiy funksiya: qabul qiluvchi va xabarni olib, to'rt natijadan birini qaytaradi. Pochta, WebPush, Telegram boti — har biri shu shaklga o'raladi (loyihaga ulanganda). Markaz ular haqida hech narsa bilmaydi.
Klassning asosiy qismi — bildir, kanal tanlash va yetkaz. Telefon uchun uzun qatorlarni va SQL matnlarini bo'ldik, ma'nosi kanondagidek:
export class BildirishnomaMarkazi {
readonly #db: Pool;
readonly #navbat: Navbat;
readonly #limit: Record<Kanal, number>;
constructor(
db: Pool,
navbat: Navbat,
limit: Partial<Record<Kanal, number>> = {},
) {
this.#db = db;
this.#navbat = navbat;
this.#limit = { ...SOATLIK_LIMIT, ...limit };
}
/** Bildirishnoma yaratadi. `dedupKalit` bilan takror chaqiruv —
* yangi yozuv ham, yangi yuborish ham yo'q (`yangi: false`). */
async bildir(fId: number, x: BildirKirish): Promise<BildirNatija> {
const c = await this.#db.connect();
let natija: BildirNatija;
try {
await c.query("BEGIN");
const r = await c.query<{ id: string }>(
`INSERT INTO bildirishnomalar
(foydalanuvchi_id, tur, sarlavha, matn, havola,
dedup_kalit)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (foydalanuvchi_id, dedup_kalit) DO NOTHING
RETURNING id`,
[fId, x.tur, x.sarlavha, x.matn, x.havola ?? null,
x.dedupKalit ?? null],
);
const yangiId = r.rows[0]?.id;
if (yangiId === undefined) {
const eski = await c.query<{ id: string }>(
`SELECT id FROM bildirishnomalar
WHERE foydalanuvchi_id = $1 AND dedup_kalit = $2`,
[fId, x.dedupKalit],
);
await c.query("COMMIT");
const id = Number(eski.rows[0]?.id);
return { id, yangi: false, kanallar: [] };
}
const kanallar = await this.#kanallarniTanla(
c, fId, x.kanallar ?? KANALLAR,
);
if (kanallar.length > 0) {
await c.query(
`INSERT INTO yetkazishlar (bildirishnoma_id, kanal)
SELECT $1, unnest($2::text[])`,
[yangiId, kanallar],
);
}
await c.query("COMMIT");
natija = { id: Number(yangiId), yangi: true, kanallar };
} catch (e) {
await c.query("ROLLBACK");
throw e;
} finally {
c.release();
}
// COMMIT'dan KEYIN: navbat bazadagi qatorni ko'rishi aniq.
// Bu yerda jarayon o'lsa — qolibKetganlar() topadi.
if (natija.kanallar.length > 0) {
await this.#navbat.qosh(natija.kanallar.map(
(kanal) => ({ bildirishnomaId: natija.id, kanal }),
));
}
return natija;
}
async #kanallarniTanla(
c: PoolClient, fId: number, soralgan: readonly Kanal[],
): Promise<Kanal[]> {
const r = await c.query<{
sozlama: Record<string, unknown> | null;
telegram: boolean;
push: boolean;
}>(
`SELECT f.sozlamalar->'bildirishnoma' AS sozlama,
EXISTS (SELECT 1 FROM telegram_ulanishlar t
WHERE t.foydalanuvchi_id = f.id AND t.faol)
AS telegram,
EXISTS (SELECT 1 FROM push_obunalar p
WHERE p.foydalanuvchi_id = f.id) AS push
FROM foydalanuvchilar f WHERE f.id = $1`,
[fId],
);
const q = r.rows[0];
if (!q) return [];
const yoqilgan = (k: Kanal) => {
const v = q.sozlama?.[k];
return typeof v === "boolean" ? v : SUKUT[k];
};
// telefon raqami sxemada hali yo'q (28-qism)
const manzil: Record<Kanal, boolean> = {
email: true, telegram: q.telegram, push: q.push, sms: false,
};
return soralgan.filter((k) => yoqilgan(k) && manzil[k]);
}
/** Worker chaqiradi: bitta (bildirishnoma, kanal) ni yetkazadi.
* Idempotent: allaqachon `yuborildi` bo'lsa — hech narsa
* qilmaydi. */
async yetkaz(
ish: YetkazishIshi, yuboruvchilar: Yuboruvchilar,
): Promise<YetkazNatija> {
const c = await this.#db.connect();
try {
await c.query("BEGIN");
// Qatorni qulflaymiz: ikki worker bir ishni bir vaqtda olsa —
// ikkinchisi kutadi va "allaqachon" ni ko'radi
const r = await c.query<{
holat: string; foydalanuvchi_id: string; tur: string;
sarlavha: string; matn: string; havola: string | null;
email: string; chat_id: string | null;
}>(
`SELECT y.holat, b.foydalanuvchi_id, b.tur, b.sarlavha,
b.matn, b.havola, f.email, t.chat_id
FROM yetkazishlar y
JOIN bildirishnomalar b ON b.id = y.bildirishnoma_id
JOIN foydalanuvchilar f ON f.id = b.foydalanuvchi_id
LEFT JOIN telegram_ulanishlar t
ON t.foydalanuvchi_id = f.id AND t.faol
WHERE y.bildirishnoma_id = $1 AND y.kanal = $2
FOR UPDATE OF y`,
[ish.bildirishnomaId, ish.kanal],
);
const q = r.rows[0];
if (!q || q.holat === "yuborildi" || q.holat === "otkazildi") {
await c.query("COMMIT");
return "allaqachon";
}
const fId = Number(q.foydalanuvchi_id);
const soni = await c.query<{ n: number }>(
`SELECT count(*)::int AS n FROM yetkazishlar y
JOIN bildirishnomalar b ON b.id = y.bildirishnoma_id
WHERE b.foydalanuvchi_id = $1 AND y.kanal = $2
AND y.holat = 'yuborildi'
AND y.yangilangan_vaqt > now() - interval '1 hour'`,
[fId, ish.kanal],
);
const yangila = (holat: string, xato: string | null) =>
c.query(
`UPDATE yetkazishlar
SET holat = $3, urinishlar = urinishlar + 1,
oxirgi_xato = $4, yangilangan_vaqt = now()
WHERE bildirishnoma_id = $1 AND kanal = $2`,
[ish.bildirishnomaId, ish.kanal, holat, xato],
);
const limit = this.#limit[ish.kanal];
if ((soni.rows[0]?.n ?? 0) >= limit) {
await yangila("otkazildi", `soatlik limit ${String(limit)}`);
await c.query("COMMIT");
return "otkazildi";
}
const yub = yuboruvchilar[ish.kanal];
if (!yub) {
await yangila("xato", "yuboruvchi yo'q");
await c.query("COMMIT");
return "xato";
}
const pushlar = ish.kanal === "push"
? (await c.query<{
id: string; endpoint: string;
p256dh: string; auth: string;
}>(
`SELECT id, endpoint, p256dh, auth FROM push_obunalar
WHERE foydalanuvchi_id = $1`,
[fId],
)).rows.map((p) => ({ ...p, id: Number(p.id) }))
: [];
// Tashqi so'rov tranzaksiya ichida — qulf yetkazish davomida
// ushlanadi (ataylab: takroriy yuborish yo'q). Uzoq kutish —
// yuboruvchi timeout'i bilan cheklangan.
const n = await yub(
{
foydalanuvchiId: fId, email: q.email,
chatId: q.chat_id, pushlar,
},
{
id: ish.bildirishnomaId, tur: q.tur, sarlavha: q.sarlavha,
matn: q.matn, havola: q.havola,
},
);
if (n.holat === "yuborildi") await yangila("yuborildi", null);
else if (n.holat === "eskirgan") {
if (ish.kanal === "telegram") {
await c.query(
`UPDATE telegram_ulanishlar SET faol = false
WHERE foydalanuvchi_id = $1`,
[fId],
);
}
if (ish.kanal === "push" && n.pushId !== undefined) {
await c.query("DELETE FROM push_obunalar WHERE id = $1",
[n.pushId]);
}
await yangila("xato", "manzil eskirgan");
} else {
const holat = n.holat === "qayta" ? "navbatda" : "xato";
await yangila(holat, n.sabab);
}
await c.query("COMMIT");
return n.holat === "eskirgan" ? "xato" : n.holat;
} catch (e) {
await c.query("ROLLBACK");
throw e;
} finally {
c.release();
}
}
// … qolibKetganlar, oqilmaganSoni, royxat, oqildi
}Testlar — modullar/test/bildirishnoma.test.ts, yettita. Ular haqiqiy Postgres talab qiladi: har test fayli shablon bazadan vaqtinchalik nusxa oladi. Biz Docker'siz, o'z PostgreSQL 17.6 klasterimizda (sql/001–204 qo'llangan) ishga tushirdik (formatlangan modul nusxasi, tsc va eslint 0, Node 24.21.0):
DATABASE_URL=postgres://postgres@127.0.0.1:<port>/vazifalar \
node --test test/bildirishnoma.test.ts▶ bildirishnoma markazi (Postgres)
✔ kanallar: afzallik + manzil + sukut (SMS yo'q) (30.8347ms)
✔ dedup: bir xil kalit — yangi yozuv ham navbat ham yo'q (7.1283ms)
✔ yetkazish: yuborildi, idempotent, parallel ikki worker — bitta yuborish (73.9967ms)
✔ rate limit: soatiga email 2 ta — uchinchisi o'tkaziladi (29.667ms)
✔ eskirgan manzil: Telegram 403 → ulanish faolsiz, keyingi bildirishnomada telegram tanlanmaydi; push 410 → obuna o'chadi (18.8995ms)
✔ qayta urinish holati navbatda qoladi; qolibKetganlar topadi (10.772ms)
✔ in-app: o'qilmaganlar soni, keyset ro'yxat, o'qildi (9.7393ms)
✔ bildirishnoma markazi (Postgres) (1247.576ms)
ℹ tests 7
ℹ suites 1
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1507.5349Yetti test. Birinchisi — kanal tanlash: Aziz — email + telegram, Dilnoza — hech narsa, Gulnora — email + push, Sardor — email. Keyin dedup, parallel ikki worker, soatlik limit (testda email limiti 2), eskirgan manzillar, qayta urinish va qolibKetganlar, in-app ro'yxat va havola cheklovi.
Commit xabari (modul va sxema uchun):
git commit -m "feat(bildirishnoma): markaz — kanallar, dedup, limit"vazifalar-api ga ulash 28-qism tugagach: vazifa muddati va yangi izoh hodisalari bildir() ni chaqiradi, ilovada esa qo'ng'iroqcha va ro'yxat marshrutlari paydo bo'ladi.
11. Real ishda
- Tayyor xizmatlar: Novu (ochiq kodli), Knock, Courier — kanal tanlash, afzalliklar sahifasi, shablonlar va in-app vidjetni beradi. Kanon ularni sinamagan — faqat tanishuv uchun. Bu darsdagi tuzilma ularning ichida ham bor — tanlashda nimaga qarashni bilasiz.
- Katta kompaniyalarda bildirishnoma — alohida xizmat (microservice) va alohida jamoa.
- Intervyuda (tizim dizayni): "Bildirishnoma tizimini loyihalang" — klassik savol. Javob tuzilmasi: hodisa → markaz → navbat → kanal worker'lari; dedup, afzalliklar, limit, qayta urinish, kuzatuv.
Xulosa
- Bitta markaz:
bildir()— in-app yozuv + har kanal uchunyetkazishlarqatori, bitta tranzaksiyada. - Navbatga COMMIT'dan keyin; tushmay qolgani
qolibKetganlar()bilan topiladi (outbox g'oyasi). - Kanal = afzallik (yoki sukut) + manzil; SMS sukutda o'chiq.
- Dedup kaliti (
ON CONFLICT DO NOTHING) takrorni to'xtatadi; havola — faqat ichki yo'l. FOR UPDATE+ holat tekshiruvi: ikki worker — bitta yuborish; soatlik limit —otkazildi.- Kanon kamchiligi: yuborish tranzaksiya ichida — ulanish band; muqobil — claim naqshi.
Keyingi dars: Telegram Bot API asoslari — to'rtinchi kanal: bot Telegram serveri bilan HTTP orqali qanday gaplashadi, polling va webhook farqi.
Manbalar
- PostgreSQL —
INSERT … ON CONFLICT: https://www.postgresql.org/docs/18/sql-insert.html - PostgreSQL —
SELECT … FOR UPDATE: https://www.postgresql.org/docs/18/sql-select.html#SQL-FOR-UPDATE-SHARE - Novu (ochiq kodli bildirishnoma infratuzilmasi): https://docs.novu.co/ — sinalmagan
- Kanon (IlmHamroh, 2026-10-27):
sql/202-bildirishnomalar.sql,markaz.ts, PostgreSQL 18.6. SQL misollari va modul testlari — biz ishga tushirdik (PostgreSQL 17.6, Node 24.21.0, 2026-10-28).
Izohlar (0)
Izoh yozish uchun kiring.
- Hozircha izoh yo'q. Birinchi bo'ling!