Ana içeriğe geç

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 yokturUndefinedFunctionError.

"İlk denememizde interval'ı str olarak bind ettik (örn. '1 hour') — o da DataError verdi. Doğru çözüm interval'ı Python'da hesaplayıp datetime olarak bind etmek." (prod 2026-05-13 dersi; aynı sınıf: commit 0c65139 / #419 asyncpg interval codec)

Çözüm — Interval aritmetiğini Python'a taşı

SQL'de :param - INTERVAL '...' yazma. Bunun yerine:

  1. Offset'i Python'da timedelta ile hesapla,
  2. Sonucu bir datetime olarak (ASLA str) 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, Python timedelta geçirin — asyncpg timedelta için native interval codec kullanır (örn. bucket_size = timedelta(hours=1)). Yine str geçirmeyin (DataError). Tuzak yalnızca bind + aritmetik (:param - INTERVAL '...') kombinasyonundadır; tipli bind veya timedelta argü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)

KontrolKural
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