Skip to content

Faster, non-blocking /state responses - #383

Merged
timkpaine merged 7 commits into
mainfrom
egb/serialize_swiftly
Oct 20, 2026
Merged

timkpaine merged 7 commits into
mainfrom
egb/serialize_swiftly

Conversation

@emilybarrettp72

@emilybarrettp72 emilybarrettp72 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator
  • Keeps pydantic as the serializer; removes the per-record Python round trips; moves /state off the event loop.
  • Output is identical to main.

Changes (one commit each)

  1. prepare_response sends bytes straight to Response (no .decode()).
  2. Batched struct serializer (GatewayStruct._dump_json_list):
    • Records of one struct type are serialized in pydantic-core calls of up to 5k records each, not one call per record.
    • Unset fields become a native exclude.
    • The timestamp callback is skipped when every timestamp is a plain naive datetime.
    • The struct's own config is kept.
    • Pieces are exact bytes, so the final join releases the GIL.
    • Falls back to per-record output for structs where native exclusion could differ:
      • a serializer that would see an unset field's None
      • two fields sharing an output key
      • extra="allow"
      • an overridden _implicitly_unset
      • an unrecognised schema shape
    • Also replaces commit 1's TypeAdapter(list[T]) path, which lost the struct's config for ser_json_inf_nan and for temporals inside Any/dict fields (e.g. /controls/stats now).
  3. /state handlers are sync, so FastAPI runs them in its threadpool.
    • DuckDBState.query now holds _db_lock around the SELECT and the object-store lookup.
    • This fixes a race on main: an engine-thread insert crossing the buffer threshold shares the query's cursor. The DuckDB error is swallowed into [], and the insert batch is lost.

Benchmarks

  • Toy gateway (MountRestRoutes, DuckDB state), Trading-like structs, seed 42.
  • Python 3.11, csp 0.19.1, pydantic 2.14.1 / core 2.50.1, fastapi 0.143.3; main and this PR alternating on the same box.
  • GET /api/v1/state/<channel> over localhost HTTP, median of 5.
  • "Stall" is the worst latency of a small async endpoint polled during the query.
  • Bodies are SHA-256 identical to main in every cell (every rep, client and round).
  • Struct1-5 Bear a striking resemblance to commonly-used structures in the domain of quantitative trading.
struct N MB main s new s speedup stall main → new s peak RSS main / new GB
Struct1 250k 115 2.28 1.34 1.70x 2.19 → 0.03 1.71 / 1.65
Struct1 500k 229 4.80 2.45 1.96x 4.97 → 0.06 2.58 / 2.46
Struct1 1M 459 9.33 5.25 1.78x 9.90 → 0.09 4.47 / 4.21
Struct3 1M 672 12.72 9.81 1.30x 11.90 → 0.10 8.08 / 6.98
Struct4 1M 531 8.79 3.90 2.26x 8.12 → 0.10 6.99 / 6.05
Struct5 1M 203 5.80 2.07 2.81x 5.56 → 0.10 2.97 / 2.96
Struct2 1M 816 20.87 11.54 1.81x 19.94 → 0.11 8.91 / 8.61

Concurrent clients, 1M records, 4 simultaneous full queries, median of 5 rounds:

struct all 4 done, main → new s small-endpoint stall, main → new s peak RSS main / new GB
Struct1 38.6 → 20.7 25.9 → 0.28 5.81 / 6.07
Struct2 85.6 → 48.1 58.8 → 0.19 11.38 / 13.07

csp engine lateness (10 ms timer, worst of 3 rounds, 1M records, max / p99):

struct 1 query: main → new ms 4 queries: main → new ms
Struct1 163 / 91 → 85 / 35 150 / 67 → 255 / 191
Struct2 328 / 135 → 104 / 30 286 / 70 → 188 / 110
  • Known regression: with 4+ concurrent full queries, engine lateness can be worse than main (Struct1 max 150 → 255 ms; Struct2 p99 70 → 110 ms). On main those queries run one at a time on the event loop; here they run in parallel threads that trade the GIL with the engine. Not addressed in this PR.
  • Peak RSS with 4 concurrent queries is up 4% (Struct1) and 15% (Struct2), since all four results are now built at once.

Verification

  • Byte-identity tests against main's per-record output cover:
    • enums, NaN/±inf, unset/explicit-None fields
    • aliases, nested and recursive structs
    • naive/aware/pandas/NaT/subclassed timestamps
    • inf/nan and timedelta config, temporals in Any/dict fields
    • field serializers, extra, mixed subclasses, chunk boundaries
  • Mutation check: removing any one of the 9 safeguards fails at least one test.
  • All 67 GatewayStruct subclasses in internal_library take the fast path and match main (350 cases).
  • Client contract diff vs main: 369 /state requests (statuses, headers, bodies, error paths, OpenAPI) are identical.
  • New concurrency tests: both fail on main.
    • query vs buffered inserts
    • /state not blocking the event loop
  • Async client: AsyncClient against a live gateway (every return type, filtered and concurrent /state calls) matches main's per-record output.
  • Review fixes: per-model type_adapter, nested serialize_by_alias, computed-field output keys; each has a test that fails without the fix.
  • Known differences from main, only for data that fails or warns:
    • Serializer warnings for several bad records are combined into one.
    • The unserializable-value error drops its "Error serializing to JSON:" prefix; the exception type is unchanged.

Records that share a type are dumped with a cached TypeAdapter(list[T])
instead of one dump_json per record, and the bytes go straight into the
Response without a decode. Mixed subclasses keep the per-record path.
Output is byte-identical.

Signed-off-by: Emily <[email protected]>
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

1 178 tests  +63   1 168 ✅ +63   10m 8s ⏱️ +19s
    1 suites ± 0      10 💤 ± 0 
    1 files   ± 0       0 ❌ ± 0 

Results for commit ca788b3. ± Comparison against base commit 652b6a6.

This pull request removes 1 and adds 64 tests. Note that renamed tests count towards both.
csp_gateway.tests.server.modules.web.test_outputs.TestOutputsApi ‑ test_file_types_without_libmagic[app.log.gz-\x1f\x8b\x08\x00\x1e#\xd1j\x02\xff\xcb\xc9OW\xc8\xc9\xccK\xe5\x02\x00G\x11\xd2\xbc\t\x00\x00\x00-application/gzip-download-]
csp_gateway.tests.server.gateway.csp.test_state ‑ test_duckdb_query_concurrent_with_buffered_inserts
csp_gateway.tests.server.modules.web.test_outputs.TestOutputsApi ‑ test_file_types_without_libmagic[app.log.gz-\x1f\x8b\x08\x00~\xc7\xd7j\x02\xff\xcb\xc9OW\xc8\xc9\xccK\xe5\x02\x00G\x11\xd2\xbc\t\x00\x00\x00-application/gzip-download-]
csp_gateway.tests.server.web.test_shared ‑ test_list_fast_path_guards_computed_field_keys
csp_gateway.tests.server.web.test_shared ‑ test_list_fast_path_ignores_core_by_alias_default
csp_gateway.tests.server.web.test_shared ‑ test_list_fast_path_keeps_model_owned_adapter
csp_gateway.tests.server.web.test_shared ‑ test_list_fast_path_keeps_nested_alias_config[child_by_alias]
csp_gateway.tests.server.web.test_shared ‑ test_list_fast_path_keeps_nested_alias_config[parent_by_alias]
csp_gateway.tests.server.web.test_shared ‑ test_list_fast_path_runs_and_chunks
csp_gateway.tests.server.web.test_shared ‑ test_list_fast_path_shared_unset_scales_linearly
csp_gateway.tests.server.web.test_shared ‑ test_list_serializers_fall_back_where_output_could_differ
…

♻️ This comment has been updated with latest results.

@codecov

codecov Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.92844% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.60%. Comparing base (652b6a6) to head (ca788b3).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
csp_gateway/tests/server/web/test_state_routes.py 95.72% 5 Missing ⚠️
csp_gateway/tests/server/gateway/csp/test_state.py 95.38% 3 Missing ⚠️
csp_gateway/utils/struct/base.py 98.14% 2 Missing ⚠️
csp_gateway/server/web/routes/state.py 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #383      +/-   ##
==========================================
+ Coverage   88.37%   88.60%   +0.23%     
==========================================
  Files         165      167       +2     
  Lines       20875    21465     +590     
  Branches     1914     1948      +34     
==========================================
+ Hits        18448    19020     +572     
- Misses       2427     2445      +18     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@emilybarrettp72
emilybarrettp72 marked this pull request as draft October 16, 2026 19:24
Records of one GatewayStruct type go to pydantic-core in calls of up to
5k records, rather than one call per record that calls back into Python
through _drop_implicitly_unset and _serialize_timestamp. Unset fields
become a native exclude, and the timestamp callback is skipped for runs
of plain naive datetimes. Output stays byte-identical to dumping each
record; structs where native exclusion could differ fall back to that.

Also replaces the list[T] TypeAdapter from the previous commit, which
dropped the struct's own config (ser_json_inf_nan, and temporals inside
Any or dict fields).

Signed-off-by: Emily <[email protected]>
The /state handlers are now sync, so FastAPI runs the query and its
serialization in its threadpool instead of holding the event loop: one
large state no longer stalls every other request until it finishes.

DuckDBState.query now holds _db_lock around the SELECT and the object
store lookup. An engine-thread insert crossing the buffer threshold runs
load_data on the same cursor; interleaved, DuckDB raises, _StateManager
returns [] for the query, and the buffered insert batch is lost.

Signed-off-by: Emily <[email protected]>
@emilybarrettp72 emilybarrettp72 changed the title Serialize REST responses in one pydantic call Faster, non-blocking /state responses Oct 16, 2026
@emilybarrettp72
emilybarrettp72 marked this pull request as ready for review October 16, 2026 21:56
@timkpaine

Copy link
Copy Markdown
Member

1. Preserve Model-Owned Adapters

Major. Comment location

The fast path calls type_adapter_for() but discards its result. A model whose
adapter overrides dump_json() to redact a field exposes that field when
batched. Please preserve the model-owned adapter or fall back when it has custom
behavior. This reproduces even with a single record.

from pydantic import TypeAdapter

from csp_gateway import GatewayStruct
from csp_gateway.server.web.routes.shared import prepare_response
from csp_gateway.utils.struct.base import type_adapter_for


class RedactingAdapter(TypeAdapter):
    def dump_json(self, instance, **kwargs):
        return super().dump_json(instance, exclude={"secret"}, **kwargs)


class Record(GatewayStruct):
    public: str = "visible"
    secret: str = "private"

    @classmethod
    def type_adapter(cls):
        return RedactingAdapter(cls)


record = Record.from_field_values()
expected = b"[" + type_adapter_for(record).dump_json(record) + b"]"
actual = prepare_response([record], is_list_model=True).body
print("Per record:", expected.decode())
print("Batched:   ", actual.decode())
assert actual == expected, "model-owned adapter was bypassed"

Observed output before the assertion:

Per record: [{"public":"visible"}]
Batched:    [{"public":"visible","secret":"private"}]

2. Preserve Nested Alias Configuration

Major. Comment location

Passing the parent's by_alias boolean forces that setting onto nested models,
overriding their own configuration. Here the child's renamed output key becomes
value. The inverse configuration also changes output. Please preserve
per-model alias defaults; explicitly passing by_alias=None fixes this repro.

from pydantic import ConfigDict, Field

from csp_gateway import GatewayStruct
from csp_gateway.server.web.routes.shared import prepare_response
from csp_gateway.utils.struct.base import type_adapter_for


class Child(GatewayStruct):
    model_config = ConfigDict(serialize_by_alias=True)
    value: int = Field(default=1, serialization_alias="renamed")


class Parent(GatewayStruct):
    child: Child


record = Parent.from_field_values(child=Child.from_field_values())
expected = b"[" + type_adapter_for(record).dump_json(record) + b"]"
actual = prepare_response([record], is_list_model=True).body
print("Per record:", expected.decode())
print("Batched:   ", actual.decode())
assert actual == expected, "nested alias configuration was overridden"

Observed output before the assertion:

Per record: [{"child":{"renamed":1}}]
Batched:    [{"child":{"value":1}}]

3. Include Computed Fields in the Collision Guard

Minor. Comment location

The duplicate-output-key check only considers regular fields. A computed field
can alias an unset field's output key too. The per-record serializer removes
that key after serialization; native exclusion leaves the computed value in the
response. Please include computed-field output keys in the fallback guard.

from pydantic import ConfigDict, computed_field

from csp_gateway import GatewayStruct
from csp_gateway.server.web.routes.shared import prepare_response
from csp_gateway.utils.struct.base import type_adapter_for


class Record(GatewayStruct):
    model_config = ConfigDict(serialize_by_alias=True)
    value: int

    @computed_field(alias="value")
    @property
    def computed(self) -> int:
        return 42


record = Record.from_field_values()
expected = b"[" + type_adapter_for(record).dump_json(record) + b"]"
actual = prepare_response([record], is_list_model=True).body
print("Per record:", expected.decode())
print("Batched:   ", actual.decode())
assert actual == expected, "computed-field alias escaped unset-field removal"

Observed output before the assertion:

Per record: [{}]
Batched:    [{"value":42}]

4. Require Overlap in the Concurrency Test

Minor, test coverage. Comment location

This test can pass without querying while the writer is active. If the writer
finishes first, sizes remains empty and the sorted assertion passes. The repro
forces that scheduling order and runs the actual test unchanged. Please use
events or barriers to force query/insert contention, with bounded waits and an
assertion that the intended overlap occurred. This is a test gap, not evidence
that the new production lock is incorrect.

import threading
from importlib import import_module
from unittest.mock import patch


tests = import_module("csp_gateway.tests.server.gateway.csp.test_state")
state_module = import_module("csp_gateway.server.gateway.csp.state")
original_start = threading.Thread.start
original_query = state_module.DuckDBState.query
writer_finished = threading.Event()
query_overlaps = []


def start_writer_first(thread):
    original_start(thread)
    thread.join(timeout=10)
    assert not thread.is_alive(), "writer did not finish within the bound"
    writer_finished.set()


def observe_query(state, *args, **kwargs):
    query_overlaps.append(not writer_finished.is_set())
    return original_query(state, *args, **kwargs)


with (
    patch.object(threading.Thread, "start", start_writer_first),
    patch.object(state_module.DuckDBState, "query", observe_query),
):
    tests.test_duckdb_query_concurrent_with_buffered_inserts()

assert query_overlaps == [False]
print("Existing concurrency test: passed")
print("Queries during writer:   ", sum(query_overlaps))
print("Final count queries:     ", len(query_overlaps))

Observed output:

Existing concurrency test: passed
Queries during writer:    0
Final count queries:      1

- Structs with their own type_adapter take the per-record path, so an
  adapter that changes the output (e.g. redacts a field) is honoured.
- Pass the same by_alias TypeAdapter.dump_json passes (None on current
  pydantic), so nested models keep their own serialize_by_alias rather
  than inheriting the parent's.
- Count computed-field output keys in the shared-key guard: popping an
  unset field's key also dropped a computed field emitted under it.
- The DuckDB concurrency test now parks the writer mid-load until a
  query starts and asserts the overlap happened.
- Add AsyncClient tests against a live gateway: every return type and
  concurrent state calls match the per-record output.

Signed-off-by: Emily <[email protected]>

@timkpaine timkpaine left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. Handle Plain Field Validators in the Batch Guard

Major. Comment location

_serializers_reached_by_none() assumes every function-* schema contains an
inner schema. A plain field validator produces a function-plain node without
that key. For a field declared without an explicit default, the batch guard
raises KeyError('schema') even when the record supplies a valid value.

Per-record serialization succeeds. The same payload changes /state from HTTP
200 to 500, and the websocket serialization path fails too. Please treat plain
validators as terminal nodes or fall back to per-record serialization.

Standalone repro, run from the PR checkout at 88746524:

from pydantic import field_validator

from csp_gateway import GatewayStruct
from csp_gateway.server.web.routes.shared import prepare_response
from csp_gateway.utils.struct.base import type_adapter_for


class Record(GatewayStruct):
    value: int

    @field_validator("value", mode="plain")
    @classmethod
    def parse_value(cls, value):
        return int(value)


record = Record.from_field_values(value="3")
expected = b"[" + type_adapter_for(record).dump_json(record) + b"]"
print("Per record:", expected.decode())
actual = prepare_response([record], is_list_model=True).body
assert actual == expected

Observed result, with the traceback shortened:

Per record: [{"value":3}]
KeyError: 'schema'

2. Synchronize at the Query/Insert Boundary

Minor, test reliability. Comment location

The revised test prevents the old zero-query false pass, but query_started
is signaled before entering the query and overlap is counted only after it
returns. If the writer finishes between those points, the test fails despite
correct production locking. Please coordinate at the actual SELECT/insert
boundary rather than relying on the writer still being alive afterward.

This repro runs the existing test and forces that valid scheduling order: after
the query-start signal, let the writer finish before calling the original query.
It leaves the production locks intact and uses a bounded join, not a sleep.

Standalone repro, run from the PR checkout at 88746524 with test dependencies:

import threading
from importlib import import_module
from unittest.mock import patch


tests = import_module("csp_gateway.tests.server.gateway.csp.test_state")
state_module = import_module("csp_gateway.server.gateway.csp.state")
original_start = threading.Thread.start
original_query = state_module.DuckDBState.query
writers = []


def capture_writer(thread):
    writers.append(thread)
    return original_start(thread)


def writer_finishes_first(state, *args, **kwargs):
    writer = writers[0]
    writer.join(timeout=10)
    assert not writer.is_alive(), "writer did not finish within the bound"
    rows = original_query(state, *args, **kwargs)
    print("Rows returned:", len(rows))
    return rows


with (
    patch.object(threading.Thread, "start", capture_writer),
    patch.object(state_module.DuckDBState, "query", writer_finishes_first),
):
    tests.test_duckdb_query_concurrent_with_buffered_inserts()

Observed result, omitting an unrelated schema warning and shortening the traceback:

Rows returned: 5000
AssertionError: no query ran while the writer was inserting

Both repros were verified with Python 3.11.17 and Pydantic 2.14.0. The exceptions
above are intentional: the first demonstrates the response crash; the second
demonstrates a false test failure, not a production locking defect.

@timkpaine
timkpaine merged commit 0932a2f into main Oct 20, 2026
10 checks passed
@timkpaine
timkpaine deleted the egb/serialize_swiftly branch October 20, 2026 20:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants