Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .claude/skills/epint/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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ı

Expand Down
85 changes: 64 additions & 21 deletions src/epint/modules/authentication/auth_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import logging
import os
import tempfile
import threading
import datetime
from typing import Dict, Tuple, Optional
from dataclasses import dataclass
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/epint/modules/version/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__}"
Expand Down
51 changes: 51 additions & 0 deletions tests/test_auth_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading