deer-flow/backend/tests/test_persistence_json_safe_dumps.py
hataa 3027ab6714
fix(persistence): normalize non-finite floats before every JSON column write (#7733)
* fix(persistence): normalize non-finite floats before every JSON column write

The shared ORM engine serialized JSON columns with the lenient json.dumps
default (allow_nan=True), and the sync agent-storage engine set no
json_serializer at all. A non-finite float reaching a JSON column — run
metadata merges caller-supplied values, goal_verdict and
token_usage_by_model carry model/provider numbers, and json.loads('1e400')
yields inf — was emitted as the bare NaN/Infinity token, which PostgreSQL
jsonb rejects as invalid JSON, failing the whole write with DataError.
SQLite silently accepts and round-trips the invalid token, so the failure
was postgres-only and environment-dependent.

Adds dumps_json_safe/json_safe_value to persistence/json_compat.py (same
None-normalization the canonical display serializer applies since #7670,
which explicitly does not cover persisted state) and wires it into both
engines' json_serializer. Regressions pin: nested non-finite floats
normalize everywhere, finite values/bools pass through unchanged, the
async engine kwargs and the sync agent engine (postgres only; SQLite takes
no json_serializer) both route through it.

* fix(persistence): normalize JSON across synchronous engines

* docs: keep frontend guidance within the CI size budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-31 18:12:32 +08:00

155 lines
6.7 KiB
Python

"""PostgreSQL JSON columns must never receive bare NaN/Infinity tokens.
The shared ORM engine's ``json_serializer`` (:func:`deerflow.persistence.engine._json_serializer`)
and the sync agent-storage and skill-mutation engines all use the same serializer.
Without it they serialize JSON columns with the lenient ``json.dumps`` default
(``allow_nan=True``). A non-finite float reaching a JSON column — run metadata
merges caller-supplied values, ``goal_verdict`` and ``token_usage_by_model``
carry model/provider numbers — is emitted as the bare ``NaN`` / ``Infinity``
token, which PostgreSQL's jsonb rejects as invalid JSON, turning the write
into a ``DataError``. SQLite silently accepts and round-trips the invalid
token instead, so the failure is postgres-only and environment-dependent.
The display/wire surfaces got the same normalization in the canonical
serializer (#7670), which explicitly does not cover persisted state; the
Gateway already uses ``allow_nan=False`` at three hand-picked sites. This pins
the shared persistence layer to the same contract: non-finite floats become
``None`` before any JSON column is written.
"""
from __future__ import annotations
import json
from deerflow.persistence.engine import _json_serializer, _postgres_engine_kwargs
from deerflow.persistence.json_compat import dumps_json_safe, json_safe_value
def _strict_loads(payload: str):
"""json.loads, but bare NaN/Infinity/-Infinity tokens fail loudly."""
def _reject(token):
raise AssertionError(f"bare {token} token reached the wire: {payload}")
return json.loads(payload, parse_constant=_reject)
def test_non_finite_floats_become_none_everywhere():
payload = dumps_json_safe({"a": float("inf"), "b": float("-inf"), "c": float("nan"), "nested": [float("inf"), {"d": float("nan")}], "keep": 1.5})
parsed = _strict_loads(payload)
assert parsed == {"a": None, "b": None, "c": None, "nested": [None, {"d": None}], "keep": 1.5}
def test_finite_values_and_bools_pass_through_unchanged():
value = {"s": "文", "i": 3, "b": True, "f": 0.25, "n": None, "l": [1, False]}
assert _strict_loads(dumps_json_safe(value)) == value
assert json_safe_value(value) == value
def test_async_postgres_engine_serializer_is_safe():
kwargs = _postgres_engine_kwargs(echo=False, pool_size=5)
assert kwargs["json_serializer"] is _json_serializer
assert _strict_loads(kwargs["json_serializer"]({"x": float("inf")})) == {"x": None}
def test_sync_agent_engine_wires_the_safe_serializer_for_postgres(monkeypatch):
import deerflow.persistence.agents.sql as agents_sql
captured: dict[str, object] = {}
def fake_create_engine(url, **kwargs):
captured["url"] = url
captured["kwargs"] = kwargs
engine = type("FakeEngine", (), {"dialect": type("D", (), {"name": "postgresql"})()})()
return engine
monkeypatch.setattr(agents_sql, "create_engine", fake_create_engine)
agents_sql._build_engine("postgresql+psycopg://u:p@localhost/db")
assert captured["url"].startswith("postgresql")
serializer = captured["kwargs"].get("json_serializer")
assert serializer is not None, "the sync postgres engine must set a json serializer"
assert _strict_loads(serializer({"x": float("nan")})) == {"x": None}
def test_sync_agent_engine_normalizes_sqlite_json_columns(tmp_path):
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from deerflow.persistence.agents.model import AgentRow
from deerflow.persistence.agents.sql import _build_engine
engine = _build_engine(f"sqlite:///{tmp_path / 'agents.db'}")
value = {"label": "文", "keep": [1.5, True, None], "nested": [float("nan"), {"positive": float("inf"), "negative": float("-inf")}]}
expected = {"label": "文", "keep": [1.5, True, None], "nested": [None, {"positive": None, "negative": None}]}
try:
AgentRow.__table__.create(engine)
with Session(engine) as session, session.begin():
session.add(AgentRow(id="agent", user_id="owner", name="test", config=value))
with Session(engine) as session:
raw = session.scalar(text("SELECT config FROM agents WHERE id = 'agent'"))
assert _strict_loads(raw) == expected
assert session.scalar(select(AgentRow.config)) == expected
finally:
engine.dispose()
def test_sync_skill_mutation_engine_wires_the_safe_serializer_for_postgres(monkeypatch):
from types import SimpleNamespace
from deerflow.skills.mutations import topology
captured = {}
def fake_create_engine(url, **kwargs):
captured.update(kwargs)
return SimpleNamespace()
monkeypatch.setattr(topology, "create_engine", fake_create_engine)
topology.mutation_session_factory("postgresql+psycopg://u:p@localhost/db")
serializer = captured.get("json_serializer")
assert serializer is not None, "the skill-mutation engine must set a json serializer"
assert _strict_loads(serializer({"x": float("nan")})) == {"x": None}
def test_sync_skill_mutation_engine_normalizes_sqlite_json_columns(tmp_path):
from sqlalchemy import select, text
from deerflow.persistence.skill_mutations.model import SkillProposalRow
from deerflow.persistence.user.model import UserRow
from deerflow.skills.mutations.topology import mutation_session_factory
engine, sessions = mutation_session_factory(f"sqlite:///{tmp_path / 'mutations.db'}")
value = {"label": "文", "keep": [1.5, True, None], "nested": [float("nan"), {"positive": float("inf"), "negative": float("-inf")}]}
expected = {"label": "文", "keep": [1.5, True, None], "nested": [None, {"positive": None, "negative": None}]}
try:
UserRow.__table__.create(engine)
SkillProposalRow.__table__.create(engine)
with sessions.begin() as session:
session.add(UserRow(id="owner", email="owner@example.test"))
with sessions.begin() as session:
session.add(
SkillProposalRow(
proposal_id="proposal",
plugin_id="test",
owner_id="owner",
target_ref="target",
name="skill",
base_revision={},
sources=[],
candidate_hash="hash",
state="draft",
expires_at=1000,
check_result=value,
idempotency_key="key",
request_hash="hash",
)
)
with sessions() as session:
raw = session.scalar(text("SELECT check_result FROM skill_mutation_proposals WHERE proposal_id = 'proposal'"))
assert _strict_loads(raw) == expected
assert session.scalar(select(SkillProposalRow.check_result)) == expected
finally:
engine.dispose()