mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-24 17:35:19 +08:00
fix(runner): normalize PostgreSQL journal timestamps to UTC-naive
Preserve the published timezone-less schema and epoch API contract across run lifecycle, deadlines, leases, heartbeat registry, events, transcripts and retention cutoffs. Exercise the real Host journal with asyncpg under a non-superuser role on metadata and published-migration schemas; 32 PostgreSQL regressions fail before the fix and pass after it.
This commit is contained in:
@@ -11,6 +11,8 @@ import sqlalchemy
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from ...persistence.datetime_utils import as_naive_utc
|
||||
|
||||
from ...entity.persistence.event_log import EventLog
|
||||
|
||||
|
||||
@@ -106,7 +108,7 @@ class EventLogStore:
|
||||
event = EventLog(
|
||||
event_id=event_id,
|
||||
event_type=event_type,
|
||||
event_time=event_time,
|
||||
event_time=as_naive_utc(event_time),
|
||||
source=source,
|
||||
bot_id=bot_id,
|
||||
workspace_id=workspace_id,
|
||||
@@ -123,7 +125,7 @@ class EventLogStore:
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
metadata_json=json.dumps(metadata) if metadata else None,
|
||||
created_at=_utc_now(),
|
||||
created_at=as_naive_utc(_utc_now()),
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
@@ -279,7 +281,9 @@ class EventLogStore:
|
||||
) -> int:
|
||||
"""Delete EventLog rows created before the supplied timestamp."""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(sqlalchemy.delete(EventLog).where(EventLog.created_at < before))
|
||||
result = await session.execute(
|
||||
sqlalchemy.delete(EventLog).where(EventLog.created_at < as_naive_utc(before))
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import sqlalchemy
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from ...persistence.datetime_utils import as_naive_utc
|
||||
|
||||
from ...entity.persistence.agent_run import AgentRun, AgentRunEvent, AgentRuntime
|
||||
|
||||
|
||||
@@ -45,7 +47,7 @@ def _epoch_to_datetime(value: typing.Any) -> datetime.datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.datetime.fromtimestamp(float(value), UTC)
|
||||
return as_naive_utc(datetime.datetime.fromtimestamp(float(value), UTC))
|
||||
except (TypeError, ValueError, OSError):
|
||||
return None
|
||||
|
||||
@@ -138,9 +140,9 @@ class RunLedgerStore:
|
||||
queue_name=queue_name,
|
||||
priority=priority,
|
||||
requested_runtime_id=requested_runtime_id,
|
||||
created_at=now,
|
||||
started_at=now if status == 'running' else None,
|
||||
updated_at=now,
|
||||
created_at=as_naive_utc(now),
|
||||
started_at=as_naive_utc(now) if status == 'running' else None,
|
||||
updated_at=as_naive_utc(now),
|
||||
deadline_at=_epoch_to_datetime(deadline_at),
|
||||
authorization_json=_json_dumps(authorization),
|
||||
metadata_json=_json_dumps(metadata),
|
||||
@@ -172,7 +174,7 @@ class RunLedgerStore:
|
||||
sqlalchemy.and_(
|
||||
AgentRun.status == 'claimed',
|
||||
AgentRun.claim_lease_expires_at.is_not(None),
|
||||
AgentRun.claim_lease_expires_at <= now,
|
||||
AgentRun.claim_lease_expires_at <= as_naive_utc(now),
|
||||
),
|
||||
),
|
||||
sqlalchemy.or_(
|
||||
@@ -199,10 +201,10 @@ class RunLedgerStore:
|
||||
run.status = 'claimed'
|
||||
run.claimed_by_runtime_id = runtime_id
|
||||
run.claim_token = uuid.uuid4().hex
|
||||
run.claim_lease_expires_at = lease_expires_at
|
||||
run.claim_lease_expires_at = as_naive_utc(lease_expires_at)
|
||||
run.dispatch_attempts = (run.dispatch_attempts or 0) + 1
|
||||
run.last_claimed_at = now
|
||||
run.updated_at = now
|
||||
run.last_claimed_at = as_naive_utc(now)
|
||||
run.updated_at = as_naive_utc(now)
|
||||
await session.commit()
|
||||
return self._run_to_dict(run, include_claim_token=True)
|
||||
|
||||
@@ -221,8 +223,8 @@ class RunLedgerStore:
|
||||
if run is None or not _claim_is_active(run, runtime_id=runtime_id, claim_token=claim_token, now=now):
|
||||
return None
|
||||
|
||||
run.claim_lease_expires_at = now + datetime.timedelta(seconds=max(int(lease_seconds), 1))
|
||||
run.updated_at = now
|
||||
run.claim_lease_expires_at = as_naive_utc(now + datetime.timedelta(seconds=max(int(lease_seconds), 1)))
|
||||
run.updated_at = as_naive_utc(now)
|
||||
await session.commit()
|
||||
return self._run_to_dict(run)
|
||||
|
||||
@@ -248,9 +250,9 @@ class RunLedgerStore:
|
||||
run.claimed_by_runtime_id = None
|
||||
run.claim_token = None
|
||||
run.claim_lease_expires_at = None
|
||||
run.updated_at = now
|
||||
run.updated_at = as_naive_utc(now)
|
||||
if status in TERMINAL_STATUSES:
|
||||
run.finished_at = run.finished_at or now
|
||||
run.finished_at = run.finished_at or as_naive_utc(now)
|
||||
await session.commit()
|
||||
return self._run_to_dict(run)
|
||||
|
||||
@@ -264,9 +266,7 @@ class RunLedgerStore:
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
"""Release claimed runs whose claim lease has expired."""
|
||||
status = _validate_run_status(status)
|
||||
current_time = now or _utc_now()
|
||||
if current_time.tzinfo is None:
|
||||
current_time = current_time.replace(tzinfo=UTC)
|
||||
current_time = as_naive_utc(now or _utc_now())
|
||||
limit = min(max(int(limit), 1), 500)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
@@ -326,7 +326,7 @@ class RunLedgerStore:
|
||||
type=event_type,
|
||||
data_json=_json_dumps(data or {}),
|
||||
usage_json=_json_dumps(usage),
|
||||
created_at=_utc_now(),
|
||||
created_at=as_naive_utc(_utc_now()),
|
||||
source=source,
|
||||
metadata_json=_json_dumps(metadata),
|
||||
)
|
||||
@@ -360,7 +360,7 @@ class RunLedgerStore:
|
||||
type=event_type,
|
||||
data_json=_json_dumps(data or {}),
|
||||
usage_json=None,
|
||||
created_at=_utc_now(),
|
||||
created_at=as_naive_utc(_utc_now()),
|
||||
source='host',
|
||||
metadata_json=_json_dumps(metadata or {}),
|
||||
)
|
||||
@@ -392,9 +392,9 @@ class RunLedgerStore:
|
||||
run.status = status
|
||||
if status_reason is not None:
|
||||
run.status_reason = status_reason
|
||||
run.updated_at = now
|
||||
run.updated_at = as_naive_utc(now)
|
||||
if status in TERMINAL_STATUSES:
|
||||
run.finished_at = run.finished_at or now
|
||||
run.finished_at = run.finished_at or as_naive_utc(now)
|
||||
run.claimed_by_runtime_id = None
|
||||
run.claim_token = None
|
||||
run.claim_lease_expires_at = None
|
||||
@@ -439,8 +439,8 @@ class RunLedgerStore:
|
||||
run = await self._get_run_row(session, run_id)
|
||||
if run is None:
|
||||
return None
|
||||
run.cancel_requested_at = now
|
||||
run.updated_at = now
|
||||
run.cancel_requested_at = as_naive_utc(now)
|
||||
run.updated_at = as_naive_utc(now)
|
||||
run.status_reason = status_reason or run.status_reason
|
||||
await session.commit()
|
||||
return self._run_to_dict(run)
|
||||
@@ -469,7 +469,7 @@ class RunLedgerStore:
|
||||
async with self._session_factory() as session:
|
||||
runtime = await self._get_runtime_row(session, runtime_id)
|
||||
if runtime is None:
|
||||
runtime = AgentRuntime(runtime_id=runtime_id, created_at=now)
|
||||
runtime = AgentRuntime(runtime_id=runtime_id, created_at=as_naive_utc(now))
|
||||
session.add(runtime)
|
||||
|
||||
runtime.status = status
|
||||
@@ -479,9 +479,11 @@ class RunLedgerStore:
|
||||
runtime.capabilities_json = _json_dumps(capabilities or {})
|
||||
runtime.labels_json = _json_dumps(labels or {})
|
||||
runtime.metadata_json = _json_dumps(metadata or {})
|
||||
runtime.last_heartbeat_at = now
|
||||
runtime.heartbeat_deadline_at = now + datetime.timedelta(seconds=max(int(heartbeat_deadline_seconds), 1))
|
||||
runtime.updated_at = now
|
||||
runtime.last_heartbeat_at = as_naive_utc(now)
|
||||
runtime.heartbeat_deadline_at = as_naive_utc(
|
||||
now + datetime.timedelta(seconds=max(int(heartbeat_deadline_seconds), 1))
|
||||
)
|
||||
runtime.updated_at = as_naive_utc(now)
|
||||
await session.commit()
|
||||
return self._runtime_to_dict(runtime)
|
||||
|
||||
@@ -503,9 +505,11 @@ class RunLedgerStore:
|
||||
return None
|
||||
|
||||
runtime.status = status
|
||||
runtime.last_heartbeat_at = now
|
||||
runtime.heartbeat_deadline_at = now + datetime.timedelta(seconds=max(int(heartbeat_deadline_seconds), 1))
|
||||
runtime.updated_at = now
|
||||
runtime.last_heartbeat_at = as_naive_utc(now)
|
||||
runtime.heartbeat_deadline_at = as_naive_utc(
|
||||
now + datetime.timedelta(seconds=max(int(heartbeat_deadline_seconds), 1))
|
||||
)
|
||||
runtime.updated_at = as_naive_utc(now)
|
||||
if capabilities is not None:
|
||||
runtime.capabilities_json = _json_dumps(capabilities)
|
||||
if labels is not None:
|
||||
@@ -588,9 +592,7 @@ class RunLedgerStore:
|
||||
stale_after_seconds: int | float | None = None,
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
"""Mark runtimes stale when their heartbeat deadline has passed."""
|
||||
current_time = now or _utc_now()
|
||||
if current_time.tzinfo is None:
|
||||
current_time = current_time.replace(tzinfo=UTC)
|
||||
current_time = as_naive_utc(now or _utc_now())
|
||||
stale_conditions: list[typing.Any] = [
|
||||
sqlalchemy.and_(
|
||||
AgentRuntime.heartbeat_deadline_at.is_not(None),
|
||||
|
||||
@@ -11,6 +11,8 @@ import sqlalchemy
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from ...persistence.datetime_utils import as_naive_utc
|
||||
|
||||
from ...entity.persistence.transcript import Transcript
|
||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||
|
||||
@@ -110,7 +112,7 @@ class TranscriptStore:
|
||||
seq=0,
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
created_at=_utc_now(),
|
||||
created_at=as_naive_utc(_utc_now()),
|
||||
metadata_json=json.dumps(metadata) if metadata else None,
|
||||
)
|
||||
session.add(item)
|
||||
@@ -350,7 +352,9 @@ class TranscriptStore:
|
||||
) -> int:
|
||||
"""Delete Transcript rows created before the supplied timestamp."""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(sqlalchemy.delete(Transcript).where(Transcript.created_at < before))
|
||||
result = await session.execute(
|
||||
sqlalchemy.delete(Transcript).where(Transcript.created_at < as_naive_utc(before))
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Datetime values bound to LangBot's timezone-less SQL columns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
|
||||
def as_naive_utc(value: datetime.datetime | None) -> datetime.datetime | None:
|
||||
"""Keep the existing UTC-naive storage contract on SQLite and PostgreSQL.
|
||||
|
||||
Legacy naive values already represent UTC. Aware values must be converted
|
||||
to UTC *before* dropping tzinfo, including query cutoffs and deadlines.
|
||||
This is a bind-boundary conversion, not a schema or wire-format change.
|
||||
"""
|
||||
if value is None or value.tzinfo is None:
|
||||
return value
|
||||
return value.astimezone(datetime.timezone.utc).replace(tzinfo=None)
|
||||
Reference in New Issue
Block a user