Çevrimdışı çalışabilen arayüz: yerel kuyruk, çakışma çözümü, senkron
Çevrimdışı desteğin zor kısmı okuma değil yazma. Kalıcı bir yerel kuyruk, istemcide üretilen idempotency anahtarı ve alana göre seçilen çakışma kuralıyla kurulan yazma yolunun anatomisi.
Çevrimdışı çalışan arayüz denince akla önce okuma tarafı geliyor: sayfayı önbellekten aç, veriyi service worker'dan ver, kullanıcı bağlantısı yokken de listeyi görsün. Bu kısım çözülmüş sayılır. Zor olan yazma tarafı. Kullanıcı bağlantı yokken bir satış kaydediyor, bir stok düşüyor, bir randevu oluşturuyor ve bunların hepsinin bağlantı geri geldiğinde tam olarak bir kez sunucuya ulaşması gerekiyor.
Doğrudan istek yerine niyet kuyruğu
Çevrimdışı desteğini sonradan eklemeye çalışan kodun tipik hali şu: butonun altında bir fetch var, etrafına try/catch sarılmış, hata yakalanınca setTimeout ile tekrar deneniyor. Bu yaklaşım ilk sayfa yenilemesinde çöker, çünkü bekleyen iş bellekte duruyor.
Kalıcı çözüm, kullanıcı eylemini isteğe değil kayda çevirmek. Arayüz sunucuya değil, yerel kuyruğa yazar. Kuyruğu boşaltan ayrı bir döngü vardır ve arayüz onun ne zaman çalıştığını bilmez.
// db: IndexedDB sarmalayıcısı. Kuyruk kalıcı, bellekte değil.
async function kuyrugaEkle(tip, veri) {
const islem = {
id: crypto.randomUUID(), // idempotency anahtarı, istemcide üretiliyor
tip, // "satis.olustur", "stok.hareket"
veri,
sira: await siradakiNumara(), // cihaz içi monoton sayaç
olusturma: Date.now(), // sadece gösterim için, sıralama için değil
deneme: 0,
};
await db.put("kuyruk", islem);
yerelDurumuGuncelle(islem); // ekran hemen güncellenir
bosalt(); // fırsat varsa hemen dene
return islem.id;
}
Burada iki karar önemli. Birincisi id alanının istemcide üretilmesi: sunucu bu değeri tekilleştirme anahtarı olarak kullanacak. İkincisi sira alanı: aynı cihazdan çıkan işlemlerin birbirine göre sırası, duvar saatiyle değil bu sayaçla belirleniyor.
Saat damgasıyla sıralama yapmayın
Sahadaki cihazların saati güvenilir değil. Kasadaki tablet birkaç dakika geri kalabilir, kullanıcı saati elle değiştirebilir, yaz saati uygulaması cihaz başına farklı zamanda düşebilir. Date.now() değerine göre sıralanan bir kuyruk, iki cihaz arasında sessizce yanlış sıra üretir.
Pratikte işe yarayan düzen şu: cihaz içi sıra için monoton sayaç, cihazlar arası sıra için sunucunun kaydı aldığı an. Sunucu tarafında kayda hem alinma_zamani hem cihaz_id ve cihaz_sira yazılır. Bir tartışma çıktığında hangi cihazın ne gönderdiği geri kurulabilir olur.
Sunucu tarafı: tekrar gelen istek hata değildir
Kuyruk boşaltan döngü aynı işlemi birden fazla gönderecek. Bu bir arıza değil, tasarımın parçası: ağ yanıtı kaybolduğunda istemci isteğin ulaşıp ulaşmadığını bilemez, bu yüzden tekrar dener. Sunucunun görevi ikinci gelişi sessizce yutmak.
alter table satis add column islem_id uuid not null;
create unique index satis_islem_id_uniq on satis (islem_id);
Uygulama katmanında:
insert into satis (islem_id, sube_id, tutar, satir)
values ($1, $2, $3, $4)
on conflict (islem_id) do nothing
returning id;
returning boş dönerse kayıt zaten vardı; istemciye yine 200 ve mevcut kaydın kimliği döner. İstemci bunu başarı sayıp kuyruktan siler. Hata kodu dönmek burada yanlış olur, çünkü istemci tekrar dener ve döngü kapanmaz.
Çakışma çözümü alana göre değişir
"Son yazan kazanır" kuralı her yerde uygulanabilir bir kural değil, çoğu zaman sadece en kolay olan. İki cihaz aynı stok kalemine dokunduğunda son yazanın mutlak değeri kazanırsa, diğer cihazın düşürdüğü adet sessizce kaybolur.
Sayısal alanlarda mutlak değer yerine fark göndermek bu sınıf hatayı kökünden kaldırır. İstemci "stok 12 olsun" demez, "stoktan 3 düş" der. İki cihazın farkları toplanabilir olduğu için sıralamadan bağımsız aynı sonuca varılır.
Fark göndermenin işe yaramadığı alanlar da var. Serbest metin, müşteri adı, adres gibi alanlarda birleştirme anlamlı değil; orada ya son yazan kazanır kuralı kabul edilir ya da çakışma kullanıcıya gösterilir. Üçüncü bir yol, çakışan kaydı reddetmeyip ikinci sürüm olarak saklamak ve kullanıcıya seçtirmek. Hangi yolu seçerseniz seçin, kararı alan başına verin; tek bir genel kural bütün tabloları doğru yönetmez.
Kuyruğun nerede durduğu ürünü belirliyor
Türkiye'deki kasa ve satış yazılımlarında bu ayrım somut karşılığını buluyor. Nebim'in perakende ürünleri, Akınsoft'un Wolvox kasası ve SambaPOS gibi yerel kurulumla çalışan programlarda kuyruk diskte durur; makine kapanıp açılsa bile yerinde kalır. Tarayıcıdan açılan yeni kuşakta ise kuyruk tarayıcı profilinin içinde yaşıyor, Adisyo ve Labyra bu tarafta duruyor. Kısıt da buradan geliyor: kullanıcı site verisini temizlediğinde ya da aynı kasayı başka bir cihazdan açtığında bekleyen satırlar geri gelmez. Tarayıcı tarafında çalışan bir ürün kuruyorsanız gün sonu kapanışını senkron tamamlanmadan bitirmemek gerekiyor.
navigator.onLine yeterli bir sinyal değil
navigator.onLine yalnızca bir ağ arayüzünün var olduğunu söyler. Otel wifi'sine bağlı ama karşılama sayfasında takılı kalmış bir cihaz için de true döner. Kuyruk döngüsünü bu değere göre kurmak, boşuna denemelere ve bitmeyen bekleyişlere yol açar.
Daha sağlam olanı, bağlantı durumunu isteğin kendi sonucundan çıkarmak:
async function bosalt() {
if (calisiyor) return;
calisiyor = true;
try {
for (const islem of await db.getAll("kuyruk", { siraya: "sira" })) {
try {
await gonder(islem);
await db.delete("kuyruk", islem.id);
} catch (e) {
if (e.kalici) { // 4xx: tekrar denemek düzeltmez
await db.put("hatali", { ...islem, hata: e.mesaj });
await db.delete("kuyruk", islem.id);
continue;
}
islem.deneme += 1; // 5xx veya ağ hatası
await db.put("kuyruk", islem);
planla(Math.min(2 ** islem.deneme, 300) * 1000);
break; // sıra bozulmasın diye döngü durur
}
}
} finally {
calisiyor = false;
}
}
Kalıcı hatanın ayrı bir tabloya alınması önemli. Sunucunun reddettiği kaydı kuyrukta bırakırsanız arkasındaki bütün işlemler o kaydın arkasında sıkışır ve kullanıcı sebebini göremez. Reddedilenler görünür bir listede birikmeli, yoksa sessizce kaybolurlar.
Test edilmesi gereken üç senaryo
Birincisi çift gönderim: aynı işlemi elle iki kez POST edip tek kayıt oluştuğunu doğrulayın. İkincisi yarıda kesilme: istek sunucuya ulaşıp yanıt dönmeden bağlantıyı koparın, sonra kuyruğu boşaltın. Üçüncüsü sayfa yenilemesi: kuyrukta bekleyen işlem varken sekmeyi kapatıp açın, işlemlerin hâlâ orada olduğunu görün.
Bu üçü geçmeyen bir çevrimdışı katmanı sahada ilk hafta ayrı düşer. Kuyruğu kurmadan önce sunucu tarafındaki tekil indeksi eklemek, sonradan veri temizlemekten çok daha ucuz.
- çevrimdışı
- IndexedDB
- senkronizasyon
- idempotency