İçindekiler
- Problem
- 2.0.0'ın denetimleri ne buldu
- Dört tasarım kararı
- Kimsenin okumadığı cevap hiçbir şeyi sonuçlandırmaz
- Epoch'lar tek yönde ilerler
- Yeni bir epoch iki istekle doğrulanır
- Sayfaların bayt bütçesi var
- On beş tur ve atılan iki tur
- Bilinen sınırlar
- Kullanım
Problem
iOS-Offline-First-Framework, uygulamanın kayıtlarını cihazdaki bir dosyada tutan, her değişikliği kuyruğa alan ve bağlantı olduğunda sunucuyla senkronize eden bir Swift paketi. Kaydetmek hiçbir zaman ağı beklemez. 15 Ekim 2026'da yayımlanan 2.0.0, paketi tek bir OfflineStore actor'ü, iki endpoint'li bir HTTP protokolü ve bir Node referans sunucusu etrafında yeniden kurdu.
Zor kısım kuyruk değil, store'un sunucu hakkında neye inandığı. Bir push uygulanmış ama cevabı kaybolmuş olabilir. Bir sayfa bir cache'ten, geride kalmış bir replica'dan ya da yeniden başlayıp verisini kaybetmiş bir sunucudan gelebilir. Bir cihazın saati bir yıl ileride olabilir. Hangi sürümün daha yeni olduğuna dair yanlış bir tahmin, bir yazımı hata vermeden kaybettirir. Burada veri bütünlüğü tek bir şey demek: kullanıcının yaptığı bir düzenleme ya da sunucunun kabul ettiği bir yazım, sessizce daha eski bir kopyayla değiştirilmez.
2.0.0'ın denetimleri ne buldu
2.0.0'ın yayımlandığı gün iki denetim paketi okudu: biri doğruluk, diğeri kötü niyetli sunucu davranışı açısından. İkisi birlikte, mutlaka düzeltilmesi gereken on bir bulgu listeledi. Sunucunun yeniden başlaması bir revision'ı tekrar dağıtabiliyordu, bu yüzden eski bir offline düzenleme daha yeni bir yazımın üstüne kabul ediliyordu. Conflict resolver'a saklanandan eski sürümler soruluyordu, bu yüzden .serverWins kayıtları geri alıyor ve silinenleri geri getiriyordu. Yıllar ilerideki tek bir timestamp her last-write-wins çakışmasını kazanıyordu. Her push'a conflict ile cevap veren bir sunucu store'u sonsuza kadar push ettiriyordu. Uygulamanın decode edemediği tek bir kayıt bütün pull'ları durduruyordu.
İlk düzeltme turu birkaç saat sonra geldi; her düzeltmenin, düzeltmeden önce kırmızı olan bir testi vardı: her revision'da bir epoch (9314feb), resolver'a yalnız gerçek yerel değişiklikler gider (6f6c823), gelecekten gelen timestamp'ler yalnız drift sınırına kadar sayılır (b3f0cfc), conflict'ler backoff'lu başarısız deneme sayılır (e903299) ve okunamayan değişiklikler pull'u durdurmak yerine quarantinedChanges()'a ayrılır (0130fd3). Her düzeltme turu yeniden denetlendi ve denetimler her seferinde yeni bir şey buldu. 21 Ekim'de PR #34 ile birleşen 2.1.0, on beş turun sonucu; CHANGELOG'u 51 düzeltilmiş sorun listeliyor.
Dört tasarım kararı
Kimsenin okumadığı cevap hiçbir şeyi sonuçlandırmaz
Bir push cevabı kaybolabilir, yarıda kesilebilir, yerine bir proxy'nin 504 sayfası gelebilir ya da transport'un okuduğu 16 MiB'tan uzun olabilir. Önceki turlar bunların bir kısmını rejected, yani uygulanmadı saydı; oysa sunucu değişikliği uygulamış olabilir. .clientWins ile sonraki bir retryFailedChanges() bu durumda eski düzenlemeyi başka bir cihazın daha yeni düzenlemesinin üstüne geri yazabiliyordu. f7833d0'dan beri bir değişikliği yalnız okunmuş bir cevap sonuçlandırır. Geri kalan her durumda değişiklik şüpheli (in doubt) kalır: change ID'sini korur, pull sunucunun daha yeni sürümünü onun için bekletir ve aynı ID ile yapılan sonraki push ne olduğunu öğrenir.
Epoch'lar tek yönde ilerler
Referans sunucuların verdiği her revision bir epoch taşır; epoch, sunucu geçmişinin adıdır. Yeni bir epoch, store'un her şeyi baştan çekmesini ve bekleyen değişikliklerini korumasını sağlar. Önceki turlar store'un terk ettiği bir epoch'a, örneğin resetChangeToken() sonrasında, geri dönmesine izin veriyordu. Geride kalmış bir replica ya da paylaşılan bir cache, reset'in yaptığı iki isteğe tam olarak cevap verebilir ve store o zaman her kaydı geri alıyordu. f804126 ve 1799c1f'ten beri epoch'ların bir sırası var (daha uzun olan daha geç; eşit uzunlukta bayt bayt büyük olan), store terk ettiği her epoch'u emekliye ayırır, son 32'sini dosyasında tutar ve daha eski bir epoch'u her sync'te reddeder.
Yeni bir epoch iki istekle doğrulanır
Tek bir sayfa zayıf bir kanıttır, çünkü onu bir cache, bir replica ya da aynı URL'nin arkasındaki başka bir deployment gönderebilir. Bu yüzden başka bir epoch, yalnız aynı sync'in iki isteği onu bildirdiğinde sayılır: token'lı pull ve baştan pull. Bunu doğru kurmak üç tur sürdü. 0450f54 iki isteği pull'un tek bir adımına koydu, böylece tek sayfayla sınırlı bir sync bile sunucunun yeniden başladığını izler; 073bc67 kontrolü store'un duyduğu ilk epoch'a uyguladı; 2499c5e yeni bir store'un ilk sayfasına uyguladı. İki istek çelişirse sync başarı bildirmek yerine store'a dokunmadan hata verir.
Sayfaların bayt bütçesi var
Referans sunucu 256 KiB'lık kayıtları kabul ediyor ama sayfaları yalnız kayıt sayısına göre kesiyordu. Bir senaryoda 250 KiB'lık 68 kayıt, transport'un okuyabileceğinden büyük, yaklaşık 17 MB'lık bir ilk sayfa üretti; baştan pull yapan her cihaz o sayfada kalıcı olarak takıldı. Artık sunucu sayfaları 8 MiB'ta kesiyor (7a6eee1), transport bir limit gönderiyor ve sayfa çok büyükken yarısı kadar değişiklik istiyor (191d6c9) ve bir push, sunucunun gövde sınırına sığan isteklere bölünüyor (44e16f3). Sunucunun söz verdiği ve client'ın kontrol ettiği sabit bir sınır, uyarlanan bir ayardan daha kolay akıl yürütülür.
On beş tur ve atılan iki tur
İlk tur 15 Ekim'de denetimlerin ardından geldi, ikinci ve üçüncü turlar 19 Ekim'de başladı, geri kalanı 21 Ekim'de koştu. Her tur kod, test, doküman ve iki bağımsız kontrolle bitti. 14. ve 15. turlar farklı gitti.
- tur, hiç epoch görmemiş bir store'un yeni bir geçmişe nasıl başladığını değiştirdi. 13. turun kontrolünün bulduğu bir durumu kapattı, ama sonraki kontrol bir regresyon buldu: 2.0.0'ın yazdığı bir store dosyası, güncellemeden sonra ilk sync'i yarıda kesilince kayıt kaybediyordu; denemede 500 kayıt 100 ya da 200'e indi. 15. tur bunu ve başka bir deployment'tan gelen push cevaplarıyla ilgili iki bulguyu düzeltti. İki build aynı rastgele koşu düzeneğinden (16 yapılandırma ve üç seçenek setinde 80 adımlık 500 seed) geçirilince bedel görüldü: 15. turda 42 başarısız koşu, 13. turun son commit'inde 4; ayrıca 13. turda geçen dört senaryo testi kırmızıya döndü.
Bu yüzden üst üste düzeltme eklemek durdu. Release dalı 13. turun son commit'i olan dc17dd3'e döndü ve yalnız kendi testleri olan iki küçük düzeltmeyi aldı: 67564b5 pull'un beklettiği sürümü kayıtlarla birlikte migrate ediyor, deca659 ise 2.0.0 dosyasındaki gönderilmiş bir değişikliği şüpheli sayıyor. aFileOf200With500RecordsKeepsThemThroughTheFirstSync testi artık 14. turun bozduğu yükseltmeyi sabitliyor. 14. ve 15. turların başka deployment'larla ilgili düzelttikleri, kod yerine belgelenmiş sınırlara dönüştü. Release dalının son incelemesi, pull'un beklettiği bir sürümün bir push cevabının yerine geçebildiğini buldu; 6a2a863 bunu düzeltti. Sürüm public API ve OfflineFirstError'a yeni case'ler eklediği için 2.0.1 yerine 2.1.0 olarak çıktı (60843d6).
Ders şu: bir sync motorunda her düzeltme diğer kuralların dayandığı şeyi değiştirir, bu yüzden yeşil bir test süiti tek başına az şey kanıtlar. İşe yarayan, her build'i aynı düzenek ve aynı denemelerden geçirmek, sayıları karşılaştırmak ve sayılar kötüleştiğinde iki turluk işi bırakmaktı.
Bilinen sınırlar
2.1.0 başka bir deployment'tan gelen tek sayfaları dışarıda tutar, ondan gelen her cevabı değil. Bir load balancer, DNS ya da proxy bir isteği staging'e veya verisi ayrı bir bölgeye gönderirse ya da bir replica veri kaybından önceki geçmişi sunarsa store yine yanıltılabilir. Her durumun örneği, geri dönüş yolu ve planı olan açık bir issue'su var:
- #26: ilk sync'i yalnız o deployment'a ulaşan yeni bir store onun token'ını izler.
- #27: ilk sync'i yalnız daha geç epoch'lu bir deployment'a ulaşan yeni bir store kendini kilitler.
- #28: böyle bir ilk sync'ten gelen daha yüksek sequence'lı bir kopya kalır ve düzenlemesi park edilir.
- #29: veri kaybından önceki geçmişin bir replica'sı cihazın kendi kabul edilmiş yazımının yerine geçebilir (iki seed aralığında 24.000 rastgele koşunun 2'si ve 8'i).
- #30: o deployment'tan gelen tek bir conflict ya da rejected cevabı sunucunun sonraki sürümlerini gizler.
- #31: ondan gelen bir accepted cevabı, sunucu yazımı kabul etmiş gibi sayılır.
- #32: kurtarmak için store dosyasını kenara almak gerekir; baştan başlatan bir API henüz yok.
- #33: yedekten dönen ve daha yeni change ID cevaplarını tutan bir sunucu, yeniden denenen bir düzenlemeyi park ettirir.
Diğer sınırlar bilinçli: sequence göndermeyen bir sunucu sıra garantisi almaz, dosya başına tek store instance'ı, her değişiklikte bütün dosya yeniden yazılır ve kayıtlar en fazla 64 seviye iç içe olabilir. Guarantees and limits makalesi tam listeyi ve sunucunun ne yapması gerektiğini veriyor.
Kullanım
Paketi from: "2.1.0" ile ekleyin ve sunucunuza HTTPSyncTransport ya da kendi SyncTransport'unuz üzerinden bağlayın. Testler ve preview'lar için InMemorySyncServer bir sunucu gibi davranır, böylece iki store iki cihazı oynayabilir:
swift
1/// Two devices, one server: the phone writes, the laptop receives the to-do.2static func twoDevices() async throws -> (phone: [Todo], laptop: [Todo]) {3 let server = InMemorySyncServer<Todo>()4 let phone = try OfflineStore<Todo>(name: "phone", storage: InMemoryStorage(), transport: server)5 let laptop = try OfflineStore<Todo>(name: "laptop", storage: InMemoryStorage(), transport: server)6 7 try await phone.save(Todo(title: "Buy milk"))8 try await phone.sync()9 try await laptop.sync()10 return (await phone.records(), await laptop.records())11}Örnek README'den birebir alındı; README'nin kodu ReadmeSamples.swift dosyasında duruyor, örnek uygulama bu dosyayı derliyor ve test süiti çalıştırıyor. Release tag'inde swift test macOS'ta 375 Swift Testing testi koşuyor; referans sunucunun 28 Node testi var.
Kaynak kod: github.com/muhittincamdali/iOS-Offline-First-Framework
