asyncpg INTERVAL Bind Tuzağı
asyncpg ile çalışan bir raw SQL sorgusu (SQLAlchemy text(...) + named bind)
HTTP 500 veya UndefinedFunctionError / DataError ile patlıyorsa ve
sorguda INTERVAL aritmetiği varsa bu rehberi takip edin. PR #642 ile
(tüketim widget'larında) bu sınıf hata çözüldü; aynı semptom başka bir
sorguda görünüyorsa kalıp aynıdır.
Semptom
- Endpoint
text(...)raw SQL içinde:bir_tarih - INTERVAL '1 hour'benzeri bind + interval aritmetiği kullanıyor. - Request HTTP 500 dönüyor; backend log'unda:
asyncpg.exceptions.UndefinedFunctionError:
operator does not exist: timestamp with time zone >= interval
veya (interval'ı str olarak bind etmeye çalışıldıysa):
DataError: invalid input for query argument
('str' object has no attribute 'days')
- Aynı sorgu psql/TablePlus'ta elle (literal değerlerle) çalışıyor ama uygulamadan (parametreli) çalışmıyor.
Kök Sebep
asyncpg parametreleri pozisyonel ve tipsiz ($1, $2, …) gönderir.
SQL içinde şöyle bir ifade yazıldığında:
WHERE bucket >= :start_date - INTERVAL '1 hour'
asyncpg start_date bind'ini tip bilgisi olmadan gönderir; PostgreSQL
$N - INTERVAL '1 hour' alt-ifadesini önce çözmeye çalışır ve sonucu
interval tipinde çıkarsar (tipsiz $N'i interval bağlamına çeker).
Ardından bucket (timestamptz) >= interval karşılaştırması için uygun
operatör yoktur → UndefinedFunctionError.
"İlk denememizde interval'ı
strolarak bind ettik (örn.'1 hour') — o daDataErrorverdi. Doğru çözüm interval'ı Python'da hesaplayıpdatetimeolarak bind etmek." (prod 2026-05-13 dersi; aynı sınıf: commit0c65139/ #419 asyncpg interval codec)
Çözüm — Interval aritmetiğini Python'a taşı
SQL'de :param - INTERVAL '...' yazma. Bunun yerine:
- Offset'i Python'da
timedeltaile hesapla, - Sonucu bir
datetimeolarak (ASLAstr) named bind ile geçir.
Önce (hatalı)
query = text("""
SELECT bucket, ...
FROM energy_hourly
WHERE device_id = :device_id
AND bucket >= :start_date - INTERVAL '1 hour' -- ← 500 burada
AND bucket < :end_date
""")
await db.execute(query, {
"device_id": str(device_id),
"start_date": start_date,
"end_date": end_date,
})
Sonra (doğru) — PR #642
from datetime import timedelta
# Interval aritmetigini Python'a tasi; bind'i DATETIME olarak gecir.
start_minus_1h = start_date - timedelta(hours=1)
query = text("""
SELECT bucket, ...
FROM energy_hourly
WHERE device_id = :device_id
AND bucket >= :start_minus_1h -- ← tipli timestamptz bind, sorun yok
AND bucket < :end_date
""")
await db.execute(query, {
"device_id": str(device_id),
"start_minus_1h": start_minus_1h, # datetime — ASLA str
"start_date": start_date,
"end_date": end_date,
})
time_bucket/ interval argümanı farklıdır.time_bucket(:bucket_size, time)gibi bir fonksiyon argümanında interval bind etmek gerekiyorsa, Pythontimedeltageçirin — asyncpgtimedeltaiçin native interval codec kullanır (örn.bucket_size = timedelta(hours=1)). Yinestrgeçirmeyin (DataError). Tuzak yalnızca bind + aritmetik (:param - INTERVAL '...') kombinasyonundadır; tipli bind veyatimedeltaargümanı güvenlidir.
Defansif Guard (cascade önleme)
Bu sınıf hata bir CAGG/raw sorguda fırlarsa, transaction abort durumda
kalır. Cache decorator'ın retry'ı veya aynı request'teki ikinci sorgu
InFailedSQLTransactionError ile tekrar patlar. Bu yüzden CAGG
sorgularını try/except + await db.rollback() + raw fallback ile
çevreleyin (PR #642 _get_consumption_from_cagg pattern'i):
try:
result = await db.execute(query, {...})
rows = result.mappings().all()
except (ProgrammingError, OperationalError) as exc:
await db.rollback() # aborted-tx temizle -> retry/2. sorgu patlamasin
logger.warning("widget_consumption_cagg_failed_fallback_raw", ...)
return await _get_consumption_from_raw(db, device_id, start_date, end_date, period)
Önleyici Checklist (yeni raw SQL yazarken)
| Kontrol | Kural |
|---|---|
SQL'de :param - INTERVAL '...' var mı? | Olmasın — Python'da timedelta ile hesapla |
| Interval bind ediliyorsa tip ne? | timedelta (native codec) — ASLA str |
| Tarih/zaman bind ediliyorsa tip ne? | datetime — ASLA str |
| CAGG/raw sorgu fail edebilir mi? | try/except + await db.rollback() + fallback |
| Cache decorator retry'lı mı? | Rollback şart (aborted-tx cascade'i önle) |
İlgili Dokümanlar
- Tüketim widget 500 → 200 fix — API Changelog (
PR-WIDGET-CONS-500) - Veritabanı — TimescaleDB CAGG
backend/app/features/widgets/service.py—_get_consumption_from_cagg(interval bind tuzağı inline yorumu) +_get_consumption_from_raw(timedeltabucket argümanı)- Issue #643 — tüketim widget veri doğruluğu follow-up (granularite ilk-gün lookback + stale-tail; 7g/15g/30g under-report)