Tüm yazılar
Veri Otomasyonu 6 dk okuma

Zamanlanmış iş tasarımı: kilit, idempotency ve güvenli yeniden çalıştırma

Cron veya Task Scheduler işi iki kez çalışınca veri bozulmasın: Python'da taşınabilir dosya kilidi, SQLite ile idempotent yazma ve zaman penceresi mantığı.

#python #automation #data-pipeline

Zamanlanmış iş tasarımı konusunda öğrendiğim en pahalı ders şu: bir cron ya da Task Scheduler görevi, ne kadar dikkatli planlanırsa planlansın, bir gün iki kez çalışır. Sunucu yeniden başlar ve görev "kaçırılan koşumu hemen telafi et" ayarıyla tetiklenir; önceki koşum beklenenden uzun sürer ve yenisi üstüne biner; ya da biri "bir sorun oldu galiba" diye betiği elle bir daha başlatır. Asıl sorun işin iki kez çalışması değil, iki kez çalıştığında veriyi bozmasıdır: mükerrer satırlar, iki kez giden e-postalar, üst üste toplanan tutarlar.

Bu yazıda bir işin güvenle yeniden çalıştırılabilmesi için gereken üç parçayı anlatacağım: aynı anda tek kopyanın çalışmasını sağlayan tekil çalışma kilidi, aynı kaydı ikinci kez işlemeyi engelleyen idempotent yazma ve "şimdi" yerine kapanmış bir aralığa dayanan zaman penceresi mantığı. Üçünü de Python ile, Windows ve Linux'ta aynı biçimde çalışacak şekilde kodlayacağım.

Örnekler bilinçli olarak küçük tutuldu: tek dosyalık bir kilit sarmalayıcı ve SQLite üzerinde çalışan bir işleyici. Kendi otomasyonlarımda bu ölçeğin üstüne nadiren çıkıyorum; farkı yaratan şey bu üç parçanın sonradan yama olarak değil, baştan tasarıma konması.

Bir iş neden iki kez çalışır?

Önce düşmanı tanıyalım. Yıllar içinde karşılaştığım tekrar senaryoları birkaç kalıba iniyor:

  • Üst üste binme: Saatlik iş bir gün 70 dakika sürer; saat başında yeni kopya başlar ve ikisi aynı tabloya yazar.
  • Kaçırılan koşumun telafisi: Task Scheduler'daki "kaçırılan başlangıçtan sonra en kısa sürede çalıştır" seçeneği ya da Linux'ta anacron, makine açılır açılmaz birikmiş koşumları tetikler.
  • Elle yeniden başlatma: Log okunamadığı ya da sonuç görünmediği için biri betiği bir daha çalıştırır; oysa ilk koşum başarılıdır.
  • Yeniden deneme sarmalayıcıları: Betik işi bitirip çıkarken hata verir; sarmalayıcı bunu "başarısız" sayıp baştan çalıştırır.
  • Saat geçişleri ve çift sunucu: Yaz saati geçişinde yerel saatin bir saati tekrar eder; ya da aynı crontab iki makineye kopyalanmıştır.

Bu senaryolar iki sınıfa ayrılır ve her sınıfın çözümü farklıdır. Aynı anda iki kopya sorununu kilit çözer. Farklı zamanlarda aynı iş sorununu ise kilit çözemez; onun için işin kendisinin tekrarı fark etmesi, yani idempotent olması gerekir. Windows tarafında bu görevlerin sağlığını nasıl izlediğimi PowerShell ile sistem izleme betiği yazısında anlatmıştım; bu yazı aynı görevlerin veri tarafına bakıyor.

Tekil çalışma kilidi: taşınabilir dosya kilidi

Klasik yöntem PID dosyasıdır: betik başlarken kendi süreç numarasını bir dosyaya yazar, bitince siler. Zayıf noktası çökmede ortaya çıkar. Dosya silinmediği için sonraki koşumların hepsi "zaten çalışıyor" diye çekilir, ya da o numara başka bir sürece yeniden verilmiştir ve kontrol yanlış pozitif üretir. İşletim sistemi düzeyinde dosya kilidi bu sorunu ortadan kaldırır; süreç nasıl biterse bitsin kilidi çekirdek bırakır. Linux'ta fcntl.flock, Windows'ta msvcrt.locking bu işi görür. İkisini tek sarmalayıcıda birleştirdim:

import os
import sys
from contextlib import contextmanager

if sys.platform == "win32":
    import msvcrt
else:
    import fcntl


class KilitAlinamadi(RuntimeError):
    pass


def _kilitle(fd):
    if sys.platform == "win32":
        os.lseek(fd, 0, os.SEEK_SET)
        msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)   # ilk baytı kilitle, bekleme yok
    else:
        fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)


def _birak(fd):
    if sys.platform == "win32":
        os.lseek(fd, 0, os.SEEK_SET)
        msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
    else:
        fcntl.flock(fd, fcntl.LOCK_UN)


@contextmanager
def tekil_calisma(kilit_yolu):
    """Aynı işin ikinci kopyasını, ilki bitmeden başlatmaz.
    Süreç çökerse kilidi işletim sistemi kendisi bırakır."""
    fd = os.open(kilit_yolu, os.O_RDWR | os.O_CREAT)
    try:
        try:
            _kilitle(fd)
        except OSError:
            raise KilitAlinamadi(f"{kilit_yolu} başka bir süreçte kilitli") from None
        os.ftruncate(fd, 0)
        os.write(fd, str(os.getpid()).encode())   # teşhis için: kilit kimde?
        yield
    finally:
        try:
            _birak(fd)
        except OSError:
            pass
        os.close(fd)


if __name__ == "__main__":
    try:
        with tekil_calisma("satis-ozet.lock"):
            print("çalışıyor, pid", os.getpid())
            # asıl iş burada
    except KilitAlinamadi as hata:
        print("ATLANDI:", hata)
        sys.exit(0)

Sarmalayıcıyı kullanırken dikkat ettiğim noktalar:

  • Kilit dosyası yerel diskte durmalı; SMB ve NFS paylaşımlarında dosya kilitleri güvenilir değildir.
  • Her işin kendi kilit dosyası olmalı ve dosya adı işin adını taşımalı; ortak bir is.lock birbiriyle ilgisiz işleri de sıraya sokar.
  • Kilit bloklamadan alınır: ikinci kopya beklemez, anında çekilir. Bekleme istiyorsanız bu kilidin değil, zamanlayıcının işidir.
  • "ATLANDI" satırı sebebiyle birlikte loga düşmeli. Sessizce çekilen kopya, ileride "iş yapıldı mı?" sorusunda sizi kör bırakır.

İdempotent yazma: işlem anahtarı ve SQLite koruması

Kilit aynı anı korur; ama saat 12:05'te yeniden başlatılan bir işin 11:00 kayıtlarını ikinci kez toplamasını engellemez. Bunun dayanıklı çözümü, işin her biriminin bir işlem anahtarı taşıması ve o anahtarın daha önce işlenip işlenmediğinin yazma anında kontrol edilmesidir. Anahtar, girdiden türetilen deterministik bir kimliktir: sipariş numarası, dosya adı ile içerik özeti, ya da bir sonraki bölümde göreceğimiz gibi zaman penceresinin başlangıcı.

SQLite bunun için yeterli ve taşınabilir bir zemin; küçük ama sağlam veri boru hatları yazısında neden tercih ettiğimi anlatmıştım. Kritik kural şu: anahtarın "işlendi" işareti ile asıl yazma aynı transaction içinde olmalı. İkisi ayrılırsa aralarında çöken bir süreç ya işareti bırakıp işi yapmamış ya da işi yapıp işareti bırakmamış olur; iki durumda da koruma kaybolur.

import sqlite3
from datetime import datetime, timezone

SEMA = """
CREATE TABLE IF NOT EXISTS islenen (
    islem_anahtari TEXT PRIMARY KEY,
    islendi_utc    TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ozet (
    gun    TEXT PRIMARY KEY,
    toplam REAL NOT NULL
);
"""


def isle(db_yolu, kayitlar):
    """kayitlar: (islem_anahtari, gun, tutar) demetleri.
    Daha önce görülen anahtar için yazma adımı atlanır."""
    con = sqlite3.connect(db_yolu, isolation_level=None)  # transaction'ı elle yönetiyoruz
    con.executescript(SEMA)
    islendi = atlandi = 0
    for anahtar, gun, tutar in kayitlar:
        con.execute("BEGIN IMMEDIATE")
        try:
            imza = con.execute(
                "INSERT OR IGNORE INTO islenen (islem_anahtari, islendi_utc) VALUES (?, ?)",
                (anahtar, datetime.now(timezone.utc).isoformat()),
            )
            if imza.rowcount == 0:        # anahtar zaten var: bu bir tekrar
                con.execute("ROLLBACK")
                atlandi += 1
                continue
            con.execute(
                "INSERT INTO ozet (gun, toplam) VALUES (?, ?) "
                "ON CONFLICT(gun) DO UPDATE SET toplam = toplam + excluded.toplam",
                (gun, tutar),
            )
            con.execute("COMMIT")         # işaret ve etki birlikte kalıcı olur
            islendi += 1
        except Exception:
            con.execute("ROLLBACK")
            raise
    con.close()
    return islendi, atlandi


if __name__ == "__main__":
    veri = [("sip-1001", "2026-09-04", 250.0), ("sip-1002", "2026-09-04", 90.0)]
    print(isle("ozet.db", veri))   # ilk koşum: (2, 0)
    print(isle("ozet.db", veri))   # aynı veriyle ikinci koşum: (0, 2)

Bu yapıyı kurarken öğrendiklerim:

  • İyi anahtar girdiden türetilir; zamana ya da rastgeleliğe bağlı olmaz. Koşum başında UUID üretirseniz ikinci koşum farklı anahtar alır ve koruma boşa düşer.
  • BEGIN IMMEDIATE yazma kilidini en baştan alır. İki süreç aynı anahtara aynı anda gelse bile biri bekler, diğeri "zaten var" görür.
  • Dış etkiler (e-posta, API çağrısı) transaction'a giremez. Orada sıra "başladı" işareti, etki, "bitti" işareti olur; mümkünse servisin kendi idempotency anahtarını da kullanın.
  • ON CONFLICT ... DO UPDATE söz dizimi SQLite 3.24 ve sonrasını ister; sqlite3.sqlite_version ile kontrol edin.

Zaman penceresi: "şimdi" değil, kapanmış aralık

Çoğu zamanlanmış iş şöyle yazılır: "son bir saatin kayıtlarını al". Bu cümle koşumun tam saatinde başladığını varsayar. İş 11:00 yerine 11:40'ta başlarsa 10:40 öncesi kayıtlar kaybolur; 11:00'de ve 11:02'de iki kez başlarsa aynı kayıtlar iki kez sayılır. Doğrusu, işin "şimdi" ile değil kapanmış pencerelerle düşünmesidir: en son tamamlanan pencerenin bitişinden başlayıp şu ana kadar kapanmış her aralığı sırayla üretmek.

from datetime import datetime, timedelta, timezone


def kapanan_pencereler(son_bitis, simdi, adim=timedelta(hours=1)):
    """son_bitis: en son işlenen pencerenin bitişi (UTC).
    Şu ana kadar tamamen kapanmış [baslangic, bitis) aralıklarını üretir;
    hâlâ açık olan son pencere bilerek dışarıda bırakılır."""
    baslangic = son_bitis
    while baslangic + adim <= simdi:
        yield baslangic, baslangic + adim
        baslangic += adim


if __name__ == "__main__":
    son = datetime(2026, 9, 4, 10, 0, tzinfo=timezone.utc)
    simdi = datetime(2026, 9, 4, 12, 5, tzinfo=timezone.utc)
    for bas, bit in kapanan_pencereler(son, simdi):
        anahtar = f"satis-ozet:{bas.isoformat()}"
        print(anahtar, "|", bit.time())
    # satis-ozet:2026-09-04T10:00:00+00:00 | 11:00:00
    # satis-ozet:2026-09-04T11:00:00+00:00 | 12:00:00

Pencere başlangıcı işlem anahtarına girince önceki iki parça birleşir: her kapanmış pencere kendi anahtarıyla isle() fonksiyonuna gider, işlenen pencerenin bitişi de "su seviyesi" olarak aynı veritabanına yazılır. Bir sonraki koşum oradan devam eder.

  • Aralık yarım açık olmalı, yani [baslangic, bitis): sınırdaki kayıt iki pencereye birden düşmez.
  • Pencereler UTC'de tutulmalı; yerel saatte yaz saati geçişi bir saati ya iki kez üretir ya da yutar.
  • Su seviyesi, pencerenin yazmasıyla aynı transaction'da güncellenmeli; yoksa "yazıldı ama seviye ilerlemedi" durumu bir sonraki koşumu tekrara zorlar.
  • Geç gelen kayıtlar için "şimdi"yi birkaç dakika geriden alın; kapanış sınırına tam oturan kayıtlar böylece bir sonraki pencereye taşmaz.

Telafi mi, tekrar mı? Aynı örnek üzerinden

Bu iki kavram sıkça karıştırılıyor; oysa fark kodda değil, verinin durumunda. Saatlik bir işi ele alalım: 10:00 koşumu tamamlanmış, 11:00 koşumu sunucu kapalı olduğu için hiç başlamamış, iş 12:05'te yeniden ayağa kalkmış olsun. Ardından 12:07'de biri betiği elle bir daha başlatsın.

saat      10:00        11:00        12:00   12:05   12:07
          |------------|------------|-------|-------|
kosum     #1 tamam     (kacirildi)          #2      #3 elle

#2  su seviyesi 10:00, kapanmis pencereler: 10-11 ve 11-12
    iki anahtar da yeni ................... ISLENDI x2   (telafi)
#3  #2 hala suruyorsa ...................... ATLANDI: kilit
    #2 bitmisse su seviyesi 12:00 ......... kapanmis pencere yok
    elle 10:00 penceresi verilirse ........ ATLANDI: anahtar mevcut (tekrar)

Telafi, hiç kapanıp işlenmemiş pencerelerin sonradan işlenmesidir; 12:05 koşumu iki pencereyi arka arkaya alır ve ikisi de yeni anahtar taşıdığı için yazılır. Tekrar ise zaten anahtarı bulunan pencerenin yeniden sunulmasıdır; kilit ya da anahtar kontrolü onu durdurur. Aynı üç parça, tek bir bayrak ya da özel durum kodu olmadan iki davranışı da doğru üretir. "Son bir saat" mantığıyla yazılmış bir iş ise 12:05'te yalnızca 11:05-12:05 aralığını alır; 10:00-11:05 arası sessizce kaybolur ve kimse fark etmez.

  • Log satırları bu üç durumu ayırt etmeli: "İŞLENDİ pencere=...", "ATLANDI sebep=kilit", "ATLANDI sebep=anahtar mevcut".
  • Uzun kesintilerden sonra telafi fırtınasını sınırlayın: bir koşumda en fazla belirli sayıda pencere işleyip kalanını bir sonraki koşuma bırakın.
  • Bu tasarım oturduktan sonra Task Scheduler'ın kendi telafi seçeneğini gönül rahatlığıyla açabilirsiniz; tekrar artık veri bozmaz, sadece bir log satırı üretir.

Özetle: kilit aynı anı, işlem anahtarı aynı işi, zaman penceresi ise aynı veriyi ikinci kez işlemekten korur. Üçü birlikte olduğunda "betiği bir daha çalıştırsam bir şey bozulur mu?" sorusunun cevabı her zaman hayırdır; bu da gece yarısı alarmlarının önemli bir kısmını baştan ortadan kaldırır. Yeni bir zamanlanmış işe başlarken artık ilk yazdığım üç parça bunlar.

Elinizde iki kez çalıştığında sorun çıkaran bir cron ya da Task Scheduler görevi varsa, veri ve otomasyon danışmanlığı sunduğum ana sayfadaki /#contact bölümünden bana yazabilirsiniz; önce hangi parçanın eksik olduğuna birlikte bakarız.