omeryanbas.com

Ömer Yanbaş

Genel Müdür, Ticofab Yazılım

MesajlaşmaVeri

Kayıttan önce gelen teslim raporu

Asenkron bir callback, ilgili satır commit edilmeden önce gelebilir. Eşleşmeyen raporu saklayın, artan gecikmeyle tekrar deneyin, sonucu kaybetmeyin.

Bir kaydı sağlayıcıya gönderiyoruz, sağlayıcı bize bir kimlik dönüyor, biz de o kaydın gönderildiğini yazıyoruz. Bir süre sonra sağlayıcı callback adresimizi arayıp aynı kimlik için nihai durumu bildiriyor. Sistem boştayken bu sıra her zaman tutar. Yük altında tutmaz ve bilinmeyen kimliği hatalı istek sayan bir handler, o kaydın alacağı tek bildirimi çöpe atar.

Gerçekte ne oluyor

Gönderim ve rapor iki ayrı yoldan gider, aralarında senkronizasyon yoktur. Gönderim bizim dışarı açtığımız HTTP isteği ya da sağlayıcıya açık oturumumuzdur. Rapor ise sağlayıcının bize açtığı, başka bir makineden, başka bir bağlantıdan, kimsenin ayarlamadığı bir anda gelen istektir.

Toplu gönderim bu aralığı içine düşülecek kadar genişletir. Sürekli karşıma çıkan sıralama şu:

  1. Bir transaction açıyoruz.
  2. Döngüde beş yüz kayıt gönderiyor, dönen kimlikleri topluyoruz.
  3. Beş yüz satırı insert ediyoruz.
  4. Commit ediyoruz.

Sağlayıcı ilk kaydı on milisaniyeler içinde kabul eder, telefon da bir saniyenin altında yanıt verebilir. Yani birinci kaydın raporu, biz henüz üç yüzüncü kaydı gönderirken gelir. Callback handler bir SELECT çalıştırır ve başka bir bağlantıdaki SELECT, commit edilmemiş bir transaction'ın satırlarını göremez. Satır eksik değildir, görünmezdir.

Sonra handler bulamadığı şeyle ne yapacağına karar verir ve alışılmış karar pahalı olanıdır:

/* raporu kaybeden sürüm */
app.post('/callbacks/report', async (req, res) => {
  const { ref, status, at } = parse(req.body);
  const row = await db.messages.findByRef(ref);
  if (!row) {
    log.warn('unknown report', { ref });
    return res.status(200).send('OK');   /* ve veri uçtu */
  }
  await db.messages.update(row.id, { state: map(status), finalisedAt: at });
  res.status(200).send('OK');
});

200 döner, ki sağlayıcı açısından doğrusu budur, ve gelen veriyi düşürür. O raporu kimse bir daha göndermez. Satır yarım saniye sonra commit olur ve biri raporlara bakıp koca bir grubun neden kaybolmuş göründüğünü sorana kadar gönderildi durumunda kalır.

Gözden kaçan üçüncü bir katkı daha var. Callback sağlayıcının kendi kimliğini taşıyorsa ve satırımız bizim kimliğimizi tutuyorsa, aradaki eşleşme için bir mapping tablosu gerekir. O mapping satırı da mesaj satırıyla aynı transaction içinde yazıldığı için görünürlük sorunu birebir aynıdır. Yani mapping tablosu eklemek kurtarmaz.

Nasıl görülür

İki sorgu ve bir log sayımı, elinizdekinin yarış mı yoksa gerçekten sessiz kalan kayıtlar mı olduğunu söyler. Önce nihai duruma hiç ulaşmamış kayıtları oluşturuldukları saate göre sayın:

SELECT date_trunc('hour', created_at) AS hour,
       count(*) FILTER (WHERE finalised_at IS NULL) AS still_open,
       count(*) AS total
FROM messages
WHERE created_at < now() - interval '6 hours'
GROUP BY 1
ORDER BY 1 DESC
LIMIT 12;

Toplama değil şekle bakın. still_open her saatte küçük ve sabit bir sayıysa, bunlar sağlayıcının hiç rapor göndermediği kayıtlardır. Saatin gönderim hacmiyle birlikte inip çıkıyorsa ve en kötü saatler en yoğun saatlerse, karşınızdaki bir yarış durumudur.

Sonra handler'ın eşleştiremediğini itiraf ettiği satırları sayın:

grep -c "unknown report" /var/log/app/callbacks.log

Bu sayı asılı kalan kayıt sayısına yakınsa raporlar gelmiş ve kod onları atmıştır. Teşhisin tamamı budur, iki dakika sürer.

Çözüm

Önem sırasına göre üç değişiklik.

Gönderimdeki kimliği callback'in taşıdığı kimlik yapın. Hemen her mesajlaşma API'si ve SMPP bağlantısı, gönderimde bir müşteri referansı kabul edip raporda aynısını geri verir. O kimliği süreçten hiçbir şey çıkmadan önce kendiniz üretin; böylece uçtan uca tek anahtar olur ve bayatlayacak bir mapping tablosu kalmaz:

const ref = newId();                       /* bizim, gönderimden önce */
await provider.submit({ to, body, clientRef: ref });
await db.messages.insert({ ref, state: 'SUBMITTED', createdAt: new Date() });

Eşleşmeyen raporu asla atmayın. Sağlayıcının gerçekten gönderdiği alanlarla anahtarlanmış küçük bir tabloya yazın:

CREATE TABLE report_inbox (
  ref          text        NOT NULL,
  status       text        NOT NULL,
  reported_at  timestamptz NOT NULL,
  received_at  timestamptz NOT NULL DEFAULT now(),
  attempts     int         NOT NULL DEFAULT 0,
  next_try_at  timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (ref, status, reported_at)
);

Buradaki primary key gerçek iş yapıyor. Sağlayıcılar raporu tekrar gönderir ve aynı referans için aynı anda aynı durumun ikinci kez gelmesi aynı bilgidir. Anahtarda çakışan insert'i ele almak yerine yok sayabilirsiniz.

Eşleştirmeyi artan gecikmeyle tekrar deneyin. Bir worker tabloyu dolaşır, her raporu bir satıra bağlamayı dener, bağlayamadıklarını ileri atar:

const BACKOFF = [1, 5, 15, 60, 300, 1800, 7200, 21600]; /* saniye */

for (const r of await inbox.due(200)) {
  const applied = await db.query(
    `UPDATE messages
        SET state = $2, finalised_at = $3
      WHERE ref = $1
        AND (finalised_at IS NULL OR finalised_at < $3)`,
    [r.ref, map(r.status), r.reported_at]
  );
  if (applied.rowCount > 0) { await inbox.remove(r); continue; }
  const next = BACKOFF[Math.min(r.attempts, BACKOFF.length - 1)];
  await inbox.defer(r, next);
}

finalised_at < $3 koşulu çözümün ikinci yarısıdır. Raporlar sırayla gelmez ve eski bir ara durum, daha yeni bir nihai durumun üstüne yazmamalıdır.

Ucuz bir ekleme yaygın durumu tekrar denemeyi beklemeden kapatır: insert transaction'ı commit olur olmaz, yazdığınız referansları rapor tablosunda arayın ve bekleyen varsa hemen uygulayın. Yarışı milisaniyelerle kaybeden raporlar için bir saniyelik gecikme böylece sıfıra iner.

Bunun maliyeti bir tablo, bir worker ve rapor başına birkaç fazladan yazmadır. Çözmediği şey, sağlayıcının hiç rapor göndermemesidir. Onlar için hâlâ, kabul ettiğiniz en uzun teslim penceresinden sonra kaydı bilinmiyor durumuna taşıyan bir kesme noktası gerekir, yoksa kuyruk sonsuza kadar büyür. Aynı mantık geçici reddetmeler için de geçerli: hız sınırı kalıcı bir hata değildir ve nihai duruma dönüşmemelidir.

Çalıştığını nasıl doğrularsınız

İzlenecek sayı kapsama oranıdır ve teslim oranıyla aynı şey değildir:

SELECT round(100.0 * count(*) FILTER (WHERE finalised_at IS NOT NULL) / count(*), 2) AS coverage_pct,
       round(100.0 * count(*) FILTER (WHERE state = 'DELIVERED')      / count(*), 2) AS delivered_pct
FROM messages
WHERE created_at BETWEEN now() - interval '48 hours' AND now() - interval '6 hours';

Kapsama, hattımızın sonucu kaydedip kaydetmediğini söyler. Teslim oranı ise rotanın çalışıp çalışmadığını. Bakımını yaptığım bir platformda kapsama aylarca doksanlı rakamlardaydı ve rapor tablosu devreye girdikten bir gün sonra yüzde 99,9'a çıktı; teslim oranı ise yüzde 82 civarında kaldı, hiç kıpırdamadı. Fark tam olarak buradadır: rota zaten o kadar iyiydi, biz sadece olup biteni yazamıyorduk.

İkinci kontrol tablonun kendisidir. Neredeyse boş kalmalıdır:

SELECT count(*) AS waiting, max(now() - received_at) AS oldest FROM report_inbox;

Yaşı saniyelerle ölçülen birkaç düzine satır, emilen sağlıklı bir yarıştır. Yaşı saatlerle ölçülen binlerce satır ise gönderimdeki referansla callback'teki referansın aslında aynı anahtar olmadığını söyler.

Nelere dikkat etmeli

  • Eşleştiremeseniz bile callback'e 2xx dönün. Orada hata üretmesi gereken tek şey, gelen veriyi kalıcı olarak yazamamaktır. Geri kalan her şey sağlayıcıya ya tekrar denemeyi ya da vazgeçmeyi öğretir.
  • Rapor tablosuna daha ilk günden saklama süresi koyun. Eşleşmeyen raporlar küçüktür ama yoğun saatlerde hızla birikir; kimsenin budamadığı tablo, sorgu sorunu olmadan çok önce disk sorunu olur.
  • Sıralama için sağlayıcının zaman damgasını, saklama için kendi aldığınız zamanı kullanın. Hangi raporun yeni olduğuna onların saati karar verir, satırın silinecek kadar eski olduğuna sizinki.
  • Üç şeye alarm kurun: yuvarlanan pencerede eşiğin altına düşen kapsama, eşleşmeyen en eski raporun yaşı ve kabul ettiğiniz en uzun teslim penceresini geçtiği hâlde nihai duruma gelmemiş kayıt sayısı. Birincisi regresyonu, ikincisi anahtar uyuşmazlığını, üçüncüsü sessizleşen sağlayıcıyı yakalar.

Buradaki asıl ders kimliğin sahipliğiyle ilgili. Bir sistem gönderdiğiniz bir şey için size geri döndüğünde, iki olayı güvenilir biçimde birbirine bağlayan tek şey, kendi ürettiğiniz ve konuşmanın iki tarafına da koyduğunuz anahtardır. Geri kalan her yöntem, commit olmamış olabilecek bir satıra, kontrol etmediğiniz bir saate ve kimsenin söz vermediği bir sıraya dayanır. Callback'i önce saklanacak sonra yorumlanacak kalıcı bir olgu olarak görün; tıpkı zamanlanmış işin damgalarının tek ve anlaşılmış bir saat diliminde yazılması gerektiği gibi. Önce saklayan kod, veriyi düşüren koddan yalnızca birkaç satır uzundur ve savunabileceğiniz bir raporla hiç olmamış gibi görünen bir grup arasındaki farkı yaratır.

Sorular ve cevaplar

Teslim raporu mesaj kaydından önce nasıl gelebilir?
Gönderim ve rapor iki ayrı istek, iki ayrı bağlantıdır ve aralarında sıra garantisi yoktur. Toplu gönderimi tek transaction içinde yapıp sonunda commit ediyorsanız, sağlayıcı ilk kaydı kabul eder, telefon yanıtlar ve callback siz hâlâ transaction içindeyken gelir. Callback içindeki sorgu commit edilmemiş satırları göremez, yani handler açısından kayıt henüz yoktur.
Callback bilinmeyen bir kimlikle geldiğinde hata dönmeli miyim?
Hayır. 2xx dışında bir yanıt sağlayıcıya sizin başarısız olduğunuzu söyler, çoğu sağlayıcı da kontrol edemediğiniz bir programla tekrar dener ya da birkaç denemeden sonra vazgeçer. Gelen veriyi 2xx ile kabul edin, saklayın, eşleştirmeyi kendi zamanınızda yapın. Bu uçtan yalnızca raporu kalıcı olarak yazamadığınızda hata dönmelisiniz.
İyi bir teslim raporu kapsama oranı nedir?
Kapsama, gönderilen kayıtların kaçının teslim ya da hata fark etmeksizin herhangi bir nihai duruma ulaştığıdır. Sağlıklı bir hatta yüzde 99'un üstü normaldir; doksanlı rakamlar genellikle raporun hiç gelmediğini değil, gelen raporun kaybedildiğini gösterir. Bu sayıyı teslim oranından ayrı takip edin: düşük teslim oranı rota sorunudur, düşük kapsama kod sorunudur.
Eşleşmeyen bir raporu ne kadar süre yeniden denemeliyim?
En yavaş yazma yolunuzdan uzun, saklama süresinden kısa. Bir saniyeden başlayıp altı saate kadar açılan ve bir gün sonra vazgeçen bir backoff, hem sıradan bir yarışı hem de saatlerce kuyrukta bekleyen bir kaydı kapsar. Bundan sonra hâlâ eşleşmeyen rapor gerçek bir uyuşmazlıktır ve ham haliyle log'a yazılmayı hak eder.