diff --git a/.claude/skills/epint/architecture.md b/.claude/skills/epint/architecture.md index f7d1afa..54f5097 100644 --- a/.claude/skills/epint/architecture.md +++ b/.claude/skills/epint/architecture.md @@ -48,6 +48,44 @@ Geliştirme / test kuralları için `usage-conventions.md`; kategori listesi iç - `seffaflik*` → yalnızca `TGT` (ST yok); `gop` → `gop-service-ticket`; diğer EPYS → `TGT`+`ST`. - TGT: EPYS 45 dk (kullanımda uzar); şeffaflık 2 saat (uzamaz). Cache: işletim sistemi temp dizini altında kullanıcı-hash'li klasör. - 401/404 ticket invalid → cache temiz + retry (`debug=True` ise HTTP atılmaz, `RequestModel` döner). +- **`Endpoint.__call__` HER çağrıda YENİ bir `Authentication(...)` nesnesi yaratır** (satır + `auth = Authentication(epint._username, epint._password, target_service, runtime_mode)`) - + paylaşılan/cache'lenmiş TEK bir instance YOK. Tek-thread kullanımda sorun değil, ama + **çoklu-thread'den (ör. `ThreadPoolExecutor` ile paralel istek atan çağıran kod) eş-zamanlı + çağrılarda** `Authentication.__init__` → `_setup_directories()` çalışır - bkz. GOTCHA aşağıda. + +### GOTCHA: paralel/çoklu-thread'den çağrıda her thread kendi TGT'sini yaratıyordu (2026-07-28, canlı testte bulundu, DÜZELTİLDİ) + +`epint.set_auth()` sonrası aynı process içinden (aynı kimlik bilgileriyle) 20 thread aynı +seffaflik-electricity servisini `ThreadPoolExecutor` ile eş-zamanlı çağırınca, log'da aynı +saniyede ~20 kez `"Yeni TGT oluşturuldu"` görülüyordu - beklenen: sadece İLK thread TGT +yaratmalı (TGT şeffaflık için 2 saat geçerli), diğerleri onu reuse etmeliydi. + +**Kök neden**: `Endpoint.__call__` her çağrıda yeni `Authentication(...)` yarattığı için (§6 +yukarıda), her thread kendi `_setup_directories()`'ini KİLİTSİZ çalıştırıyordu: +`_has_permission_issues`/`_reset_temp_directory` (paylaşılan `.epint_test` dosyasında, +`shutil.rmtree` ile TÜM temp_dir'i - tgt_dir + `.lock` dosyaları DAHİL - silebiliyordu) ve +`_test_file_permissions` (tgt_dir/st_dir'i KİLİTSİZ okuyup, okuma hatasında `os.remove` ile +siliyordu - başka bir thread'in AYNI ANDA `_store_ticket()` ile yazdığı dosyayla yarışabiliyordu). +`get_tgt()`'in KENDİ kilidi (`_locked`, `fcntl.flock`) doğruydu ama bu kilide gelmeden ÖNCEKİ +adımlar kilidin dayandığı dosya/lock-inode sürekliliğini bozabiliyordu - sonuç: her thread +"benim için geçerli TGT yok" sanıp ayrı ayrı TGT yaratıyordu. + +**Fix**: `_setup_directories()`'in yıkıcı permission-check/reset kısmı artık process başına +EN FAZLA BİR KEZ çalışır (double-checked locking: `_setup_lock` + `_verified_temp_dirs` set'i, +modül seviyesinde) - ilk `Authentication()` bu adımı tamamladıktan sonra aynı process içindeki +(thread farketmeksizin) sonraki construction'lar bu kısmı atlar. `_test_file_permissions` artık +tgt_dir/st_dir'i `get_tgt()`/`_store_ticket()` ile AYNI kilit (`_locked(self._tgt_lock_path)` / +`_locked(self._st_lock_path)`) altında okuyor - başka bir thread'in mid-write dosyasıyla artık +yarışmıyor. Regresyon testi: `tests/test_auth_manager.py` +`test_concurrent_authentication_construction_creates_single_tgt` (20 eş-zamanlı AYRI +`Authentication()` construction'ı simüle eder, fix öncesi kod bu testte `FileNotFoundError` ile +patlıyordu, fix sonrası tek bir TGT üretildiğini doğruluyor). + +**Kullanım tarafı not**: bu fix `epint` İÇİNDE - tüketici kod (ör. Airflow DAG'ları) tarafında +paralel/`ThreadPoolExecutor` ile çekim yapan mevcut desenler (bkz. airflow reposu +`dags/pmnt/_seffaflik_generation_common.py` `AdaptiveRateLimiter`) bu fix'i içeren epint +sürümüne güncellenince otomatik faydalanır, ekstra kod değişikliği gerekmez. ## 7. Tarih formatları diff --git a/src/epint/modules/authentication/auth_manager.py b/src/epint/modules/authentication/auth_manager.py index 6f74da5..6c288f5 100644 --- a/src/epint/modules/authentication/auth_manager.py +++ b/src/epint/modules/authentication/auth_manager.py @@ -18,6 +18,7 @@ import logging import os import tempfile +import threading import datetime from typing import Dict, Tuple, Optional from dataclasses import dataclass @@ -28,6 +29,27 @@ logger = logging.getLogger("epint") +# `Endpoint.__call__` (models/endpoint_callable.py) HER API cagrisinda YENI bir +# `Authentication(...)` nesnesi yaratir (paylasilan/cache'lenmis tek bir instance YOK). +# Coklu-thread'den (ör. ThreadPoolExecutor ile paralel istek atan cagiran kod) es-zamanli +# cagrilarda bu, `_setup_directories()`'in KILITSIZ permission-check/reset mantiginin +# (bkz. `_has_permission_issues`/`_reset_temp_directory`/`_test_file_permissions`) her +# thread'de TEKRAR TEKRAR calismasina yol aciyordu - bir thread'in test-yazdigi/sildigi +# dosyayla digerinin ES ZAMANLI okumasi/silmesi yarisa girip (yanlislikla) "izin sorunu var" +# sanilip TUM temp_dir'in (tgt_dir + `.lock` dosyalari DAHIL) silinmesine/yeniden +# yaratilmasina neden olabiliyordu - bu da `get_tgt()`'in KENDI kilidinin (asagida, +# `_locked`) dayandigi dosya/inode surekliligini bozup, HER thread'in "benim icin gecerli +# TGT yok" sonucuna varip AYRI AYRI TGT yaratmasina yol aciyordu (canli testte gozlemlendi: +# 20 thread es-zamanli cagirinca ~20 "Yeni TGT olusturuldu" log satiri, ayni saniyede). +# +# Fix: bu YIKICI probe/reset adimi process basina EN FAZLA BIR KEZ calisir (double-checked +# locking, `_setup_lock` + `_verified_temp_dirs`) - ilk `Authentication()` bu adimi +# tamamladiktan SONRA, ayni process icindeki (thread farketmeksizin) SONRAKI TUM +# `Authentication(...)` construction'lari bu KILITSIZ/RISKLI kismi ATLAR, sadece +# `os.makedirs(exist_ok=True)` gibi zaten idempotent/guvenli adimlari calistirir. +_setup_lock = threading.Lock() +_verified_temp_dirs: set = set() + @contextlib.contextmanager def _locked(lock_path: str): @@ -168,12 +190,20 @@ def _setup_directories(self) -> None: tempfile.gettempdir(), f"epint-{self._get_os_user_hash()}" ) - # Okuma/yazma sorunu kontrolü - if self._has_permission_issues(temp_dir): - # Klasörü sıfırla ve yeniden oluştur - self._reset_temp_directory(temp_dir) - - os.makedirs(temp_dir, exist_ok=True) + # Permission-check/reset (bkz. modul docstring'i, `_setup_lock`) SADECE process + # basina ILK `Authentication()` construction'inda calisir - double-checked locking: + # kilitsiz hizli-yol (cogunlukla True), kilit ALTINDA ikinci kontrol (yaris onlenir). + if temp_dir not in _verified_temp_dirs: + with _setup_lock: + if temp_dir not in _verified_temp_dirs: + # Okuma/yazma sorunu kontrolü + if self._has_permission_issues(temp_dir): + # Klasörü sıfırla ve yeniden oluştur + self._reset_temp_directory(temp_dir) + os.makedirs(temp_dir, exist_ok=True) + _verified_temp_dirs.add(temp_dir) + + os.makedirs(temp_dir, exist_ok=True) # idempotent - zaten var, sadece garanti # Kullanıcı bazlı ticket dosyaları - farklı EPİAŞ kullanıcıları için ticket karışmasını önle import hashlib @@ -235,26 +265,39 @@ def _reset_temp_directory(self, temp_dir: str) -> None: pass def _test_file_permissions(self) -> None: - """Ticket dosyalarının okuma/yazma izinlerini test et""" + """Ticket dosyalarının okuma/yazma izinlerini test et. + + AYNI kilit (`_locked`, `get_tgt()`/`_store_ticket()`'in kullandığı) altında okunur - + aksi halde bu okuma, başka bir thread'in (farklı bir `Authentication` nesnesi + üzerinden, bkz. modül docstring'i) AYNI ANDA `_store_ticket()` ile yazdığı dosyayı + yarım/tutarsız görüp "bozuk" sanıp SİLEBİLİYORDU - geçerli, az önce yaratılmış bir + TGT/ST bu race yüzünden yok edilip her thread'in kendi TGT'sini yaratmasına yol + açan asıl nedenlerden biriydi.""" try: - # TGT dosyası testi if os.path.exists(self.tgt_dir): - with open(self.tgt_dir, "r") as f: - f.read() + with _locked(self._tgt_lock_path): + with open(self.tgt_dir, "r") as f: + f.read() + except (IOError, OSError, PermissionError): + with _locked(self._tgt_lock_path): + try: + if os.path.exists(self.tgt_dir): + os.remove(self.tgt_dir) + except (IOError, OSError, PermissionError): + pass - # ST dosyası testi + try: if os.path.exists(self.st_dir): - with open(self.st_dir, "r") as f: - f.read() + with _locked(self._st_lock_path): + with open(self.st_dir, "r") as f: + f.read() except (IOError, OSError, PermissionError): - # Okuma sorunu varsa dosyaları sil - try: - if os.path.exists(self.tgt_dir): - os.remove(self.tgt_dir) - if os.path.exists(self.st_dir): - os.remove(self.st_dir) - except (IOError, OSError, PermissionError): - pass + with _locked(self._st_lock_path): + try: + if os.path.exists(self.st_dir): + os.remove(self.st_dir) + except (IOError, OSError, PermissionError): + pass def _get_base_headers(self) -> Dict[str, str]: return { diff --git a/src/epint/modules/version/__init__.py b/src/epint/modules/version/__init__.py index 9521520..e9d1a2e 100644 --- a/src/epint/modules/version/__init__.py +++ b/src/epint/modules/version/__init__.py @@ -14,7 +14,7 @@ __major__ = 0 # stabil release __minor__ = 7 # yeni özellik -__semantic__ = 7 # bug fix +__semantic__ = 8 # bug fix __tag__ = "dev" __version__ = f"{__major__}.{__minor__}.{__semantic__}-{__tag__}" diff --git a/tests/test_auth_manager.py b/tests/test_auth_manager.py index 5084544..ede4d12 100644 --- a/tests/test_auth_manager.py +++ b/tests/test_auth_manager.py @@ -185,6 +185,57 @@ def worker(): assert parts[0].startswith("TGT-cas-") +def test_concurrent_authentication_construction_creates_single_tgt(monkeypatch): + """Regresyon: `Endpoint.__call__` (models/endpoint_callable.py) HER API çağrısında YENİ + bir `Authentication(...)` nesnesi yaratıyor (paylaşılan/cache'lenmiş TEK bir instance + YOK). Çoklu-thread'den (ör. ThreadPoolExecutor ile paralel istek atan çağıran kod) + eş-zamanlı çağrılarda bu, `_setup_directories()`'in KİLİTSİZ permission-check/reset + mantığının (`_has_permission_issues`/`_reset_temp_directory`) HER thread'de tekrar + tekrar çalışmasına, ve `_test_file_permissions()`'ın da kilitsiz okuma yüzünden başka + bir thread'in AYNI ANDA yazdığı ticket dosyasını "bozuk" sanıp SİLMESİNE yol açıyordu - + bu da `get_tgt()`'in kendi kilidinin dayandığı dosya sürekliliğini bozup HER thread'in + "benim için geçerli TGT yok" sonucuna varıp AYRI AYRI TGT yaratmasına neden oluyordu + (canlı testte gözlemlendi: 20 eş-zamanlı çağrıda ~20 "Yeni TGT oluşturuldu" log satırı). + + Fix sonrası: aynı kullanıcı için eş-zamanlı 20 AYRI `Authentication()` construction'ı + olsa bile (gerçek `Endpoint.__call__` davranışını taklit eder - tek bir paylaşılan + instance DEĞİL) sadece TEK bir TGT yaratılmalı, hepsi onu reuse etmeli.""" + call_counter = {"n": 0} + counter_lock = threading.Lock() + + def fake_generate_tgt(self): + with counter_lock: + call_counter["n"] += 1 + n = call_counter["n"] + return f"TGT-cas-{n:03d}" + + monkeypatch.setattr(Authentication, "_generate_tgt", fake_generate_tgt) + + errors = [] + results = [] + + def worker(): + try: + # Gercek Endpoint.__call__ deseni: HER cagrida YENI bir Authentication nesnesi. + auth = Authentication("concurrent_construct_user", "pass") + code, _ = auth.get_tgt() + results.append(code) + except Exception as e: # pragma: no cover - sadece görünürlük için + errors.append(e) + + threads = [threading.Thread(target=worker) for _ in range(20)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors + assert ( + call_counter["n"] == 1 + ), f"{call_counter['n']} kez TGT yaratıldı, beklenen 1 (hepsi aynı TGT'yi reuse etmeliydi)" + assert len(set(results)) == 1 + + def test_clear_tickets_logs_via_epint_logger(monkeypatch, caplog): auth = Authentication("user1", "pass") monkeypatch.setattr(auth, "_generate_tgt", lambda: "TGT-cas-abc")