mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-02 08:30:37 +00:00
* 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>
155 lines
6.7 KiB
Python
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()
|