deer-flow/backend/tests/test_reload_boundary.py
Xinmin Zeng befe334f10
fix(config): make the reload boundary discoverable from code (#3144) (#3153)
* fix(config): make the reload boundary discoverable from code, not just docs

Closes #3144.

The hot-reload contract — per-run fields are resolved through
`get_app_config()` on every request, infrastructure fields snapshot at
gateway startup — landed in `backend/CLAUDE.md` as part of #3131. A
maintainer reading `get_config()` or an `AppConfig` field still had to
context-switch to that document to know which fields require a process
restart, and there was no enforcement that the prose list stayed in
sync with the code.

This commit moves the boundary to a machine-readable single source of
truth and surfaces it where the code lives:

- New `deerflow.config.reload_boundary` module owns the registry of
  restart-required fields (`STARTUP_ONLY_FIELDS`) and a tiny helper
  API (`is_startup_only_field`, `iter_startup_only_field_paths`,
  `format_field_description`). The standardised `"startup-only:"`
  prefix is exported as `STARTUP_ONLY_PREFIX` so future scanners /
  lint hooks / doc generators can pivot off it without re-parsing
  prose.
- `AppConfig`'s `database`, `checkpointer`, `run_events`,
  `stream_bridge`, `sandbox`, and `log_level` fields now build their
  `Field(description=...)` from `format_field_description(...)`. The
  same text shows up in IDE hover (Pydantic v2 exposes `description`
  via `model_fields[...]`).
- `channels` is restart-required too but lives outside the AppConfig
  Pydantic schema (the config section is consumed directly by
  `start_channel_service`). The registry owns it so the boundary is
  not split between two places.
- `get_config()` docstring points to the registry instead of leaving
  the reader to find `CLAUDE.md`. The `CLAUDE.md` table collapses to
  a one-liner pointing back at `reload_boundary.py` so the boundary
  has one canonical location, not two.

Drift coverage in `tests/test_reload_boundary.py`:

- Every registered field has a non-trivial reason.
- Iterator / membership helpers stay in sync with the dict.
- Every registry entry that maps to an `AppConfig` field also carries
  the `"startup-only:"` prefix in the schema (catches "forgot to
  update the schema").
- Reverse drift: any AppConfig field whose description starts with
  the prefix must be registered (catches "marked restart-required in
  the schema but forgot the registry").
- The runtime introspection that IDE hover depends on
  (`AppConfig.model_fields["database"].description`) is pinned, so a
  future Pydantic upgrade or schema swap that breaks the hover surface
  shows up as a test failure rather than a silent regression.

Refs: bytedance/deer-flow#3138 (split summary), #3107 (origin), #3131
(prior boundary fix in prose form).

* fix(config): preserve field doc and correct log_level reload reason

Two follow-ups on the PR #3153 review:

1. The `log_level` STARTUP_ONLY_FIELDS reason previously claimed
   `apply_logging_level()` mutates the root logger level. It does not:
   only the `deerflow` / `app` logger levels are set, and root handler
   thresholds are conditionally lowered so messages from those loggers
   can propagate. Reword to match the actual behavior so operators
   reading IDE hover get accurate restart guidance.

2. `format_field_description(field_path)` was the sole `Field(description=)`
   for every restart-required field, which silently overwrote the
   original human-facing documentation — most visibly the `log_level`
   field that used to list debug/info/warning/error and clarify that
   third-party libraries are not affected. Extend the helper with a
   keyword-only `field_doc` parameter that composes the startup-only
   marker with the original prose so IDE hover documents both *why*
   the field is restart-required and *what* it actually accepts.
   Updated all six restart-required AppConfig fields (`log_level`,
   `database`, `sandbox`, `run_events`, `checkpointer`, `stream_bridge`)
   to pass their original descriptions through the helper.

Tests: two new cases in `test_reload_boundary.py` pin (a) the helper
composition and (b) every AppConfig restart-required field still
surfaces a recognisable substring of its original documentation.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-06-07 21:27:14 +08:00

141 lines
6.4 KiB
Python

"""Regression tests for the config reload boundary registry.
Bytedance/deer-flow issue #3144: the hot-reload boundary is the contract
between gateway dependencies that resolve ``AppConfig`` every request and the
infrastructure that captures the snapshot once at startup. The registry in
``deerflow.config.reload_boundary`` is the machine-readable source of truth;
these tests pin the registry against the actual Pydantic schema so a future
field rename / addition / boundary change cannot silently drift.
"""
from __future__ import annotations
import pytest
from deerflow.config.app_config import AppConfig
from deerflow.config.reload_boundary import (
STARTUP_ONLY_FIELDS,
STARTUP_ONLY_PREFIX,
format_field_description,
is_startup_only_field,
iter_startup_only_field_paths,
)
def test_registry_has_a_reason_for_every_field():
"""Every registry entry must explain *why* the field is restart-required.
The reason text is what surfaces in IDE hover and in the AppConfig schema
description, so an empty / placeholder value would defeat the purpose.
"""
for field_path, reason in STARTUP_ONLY_FIELDS.items():
assert reason.strip(), f"empty reason for {field_path}"
assert len(reason) > 20, f"reason for {field_path} too short to be useful: {reason!r}"
def test_iter_startup_only_field_paths_matches_registry():
"""Iterator stays in sync with the registry mapping."""
assert sorted(iter_startup_only_field_paths()) == sorted(STARTUP_ONLY_FIELDS)
def test_is_startup_only_field_recognises_registered_fields():
"""The membership helper accepts every registered field path."""
for field_path in STARTUP_ONLY_FIELDS:
assert is_startup_only_field(field_path)
assert not is_startup_only_field("memory") # hot-reloadable
assert not is_startup_only_field("models")
assert not is_startup_only_field("nonexistent_field")
def test_format_field_description_prefixes_with_marker():
"""The formatter produces a description that machine-readable tooling can
pivot on (drift tests, future "needs-restart" scanners)."""
for field_path in STARTUP_ONLY_FIELDS:
text = format_field_description(field_path)
assert text.startswith(STARTUP_ONLY_PREFIX), text
# The reason is appended after the prefix; the formatter must not
# silently drop it.
assert STARTUP_ONLY_FIELDS[field_path] in text
def test_format_field_description_rejects_unknown_field():
with pytest.raises(KeyError):
format_field_description("not_in_registry")
def test_format_field_description_appends_optional_field_doc():
"""The formatter composes the startup-only marker with the field's own
human-facing description when supplied.
The original ``Field(description=)`` used to document allowed values
(e.g. ``log_level`` listed ``debug/info/warning/error``); registry
adoption must not drop that. The composed output keeps the marker as
the leading token so machine-readable tooling still pivots on it,
then appends the prose after a blank line.
"""
text = format_field_description("log_level", field_doc="Logging level (debug/info/warning/error).")
assert text.startswith(STARTUP_ONLY_PREFIX)
assert STARTUP_ONLY_FIELDS["log_level"] in text
assert "debug/info/warning/error" in text
def test_appconfig_descriptions_retain_original_field_documentation():
"""``AppConfig.model_fields[name].description`` for restart-required
fields should still carry the original human-facing field doc so IDE
hover documents what the field is *and* why a restart is needed."""
descriptions = {
"log_level": "debug/info/warning/error",
"database": "memory, sqlite, or postgres",
"sandbox": "Sandbox provider",
"run_events": "memory for dev",
"checkpointer": "state-persistence checkpointer",
"stream_bridge": "Stream bridge",
}
for field_name, expected_substring in descriptions.items():
description = AppConfig.model_fields[field_name].description or ""
assert description.startswith(STARTUP_ONLY_PREFIX), f"AppConfig.{field_name} missing startup-only marker"
assert expected_substring in description, f"AppConfig.{field_name} description lost original field doc; got {description!r}"
def test_appconfig_schema_marks_registered_fields_with_prefix():
"""Every registry entry that corresponds to a top-level AppConfig field
must carry the standardized ``startup-only:`` prefix in its Pydantic
``Field(description=...)``. This is the contract IDE hover relies on.
"""
schema_fields = AppConfig.model_fields
for field_path in STARTUP_ONLY_FIELDS:
if field_path not in schema_fields:
# Some entries (e.g. ``channels``) live outside the AppConfig
# schema. The registry still owns them, but the schema-prefix
# assertion does not apply.
continue
description = schema_fields[field_path].description or ""
assert description.startswith(STARTUP_ONLY_PREFIX), f"AppConfig.{field_path} should have Field(description=) starting with {STARTUP_ONLY_PREFIX!r}, got {description!r}"
def test_no_appconfig_field_uses_prefix_without_registration():
"""Reverse drift check: if a future schema edit adds the
``startup-only:`` prefix to a new field, the registry must list it.
This catches the silent-drift case where someone marks a field
restart-required in the schema but forgets to update the registry
that the operator-facing scanners and docs consume.
"""
for name, info in AppConfig.model_fields.items():
description = info.description or ""
if not description.startswith(STARTUP_ONLY_PREFIX):
continue
assert name in STARTUP_ONLY_FIELDS, f"AppConfig.{name} schema description starts with {STARTUP_ONLY_PREFIX!r} but the field is not listed in reload_boundary.STARTUP_ONLY_FIELDS — update the registry."
def test_pydantic_field_descriptions_are_introspectable_at_runtime():
"""``AppConfig.model_fields[name].description`` is the IDE-hover source.
If this read ever breaks (e.g. Pydantic deprecation, schema swap), the
IDE-hover guarantee #3144 promises silently regresses. Pin it.
"""
assert "database" in AppConfig.model_fields
description = AppConfig.model_fields["database"].description
assert description is not None
assert description.startswith(STARTUP_ONLY_PREFIX)