Skip to content

dispatch: read a request type's PeerRef fields once, not on every /v1/op call - #23

Open
erfnzdeh wants to merge 1 commit into
mainfrom
fix/dispatch-hint-cache
Open

erfnzdeh wants to merge 1 commit into
mainfrom
fix/dispatch-hint-cache

Conversation

@erfnzdeh

Copy link
Copy Markdown
Collaborator

What was wrong

normalise_peer_refs runs on every /v1/op request. Each time, it called _peer_ref_fields(spec.request), which called typing.get_type_hints on the request struct. The ops and models modules use postponed annotations, so every one of those calls evals all of the struct's annotation strings again. That costs about 78 µs per request, and the answer depends only on the request type.

Under coverage's sys.monitoring core (the default on Python 3.14), each of those evals also leaves a <string> code object that stays alive for the rest of the test run. #22 fixed the large version of this in tests/test_registry_contract.py and listed this one under "Not in this PR".

The fix

  • tlgr/daemon/dispatch.py: _peer_ref_fields is now functools.cached.
    • The cache can only hold as many entries as the registry has request types: 668 distinct types across 678 operations.
    • Measured over the whole registry on 3.14.7: about 77.6 µs per uncached call, about 0.05 µs cached.
  • Read-only result: the function now returns a MappingProxyType typed as Mapping[str, bool], since every caller shares one cached mapping.
    • normalise_peer_refs is the only caller, and it only reads the mapping.
    • Without the read-only view, one stray write would change peer parsing for every later request of that type.

Tests

  • tests/test_dispatch.py: a new test covers three things:

    • "@alice" is still parsed for a scalar PeerRef | None field and a list[PeerRef] field, and a plain str field is left alone.
    • Across three normalise_peer_refs calls, get_type_hints reads the request type only once.
    • The returned mapping rejects writes.

    I also ran the test with the cache bypassed (__wrapped__), and the read-count assertion failed, so the test catches a regression.

  • Full run: make lint typecheck test on macOS, Python 3.14.7, with TLGR_HOME set to a scratch directory. Ruff is clean, strict mypy is clean (139 files) and all 13,306 tests passed.

…/op call

`normalise_peer_refs` runs for every `/v1/op` request, and it asked
`_peer_ref_fields` to call `typing.get_type_hints` on the request struct each
time. The ops and models modules use postponed annotations, so each of those
calls `eval`s every annotation string of the struct again. That is about
78 us per request, for an answer that depends on the request type alone.

`_peer_ref_fields` is now `functools.cache`d. The key set is bounded by the
registry (668 distinct request types across 678 operations), and a cached
lookup costs about 0.05 us. Every caller now shares one mapping, so it is
returned as a `MappingProxyType`. The only caller reads it and never writes
to it, but a stray write would otherwise change peer parsing for every later
request of that type.

It also matters for the test run. On Python 3.14 coverage uses its
sys.monitoring core, which keeps every code object it sees for the rest of
the session, including the throwaway `<string>` ones from those `eval`s. So
each dispatched test left a little behind. PR #22 fixed the large case in
tests/test_registry_contract.py; this is the leftover it listed.

The new test checks that `"@alice"` is still parsed for a scalar and a list
`PeerRef` field, that three calls read the hints once, and that the returned
mapping refuses writes. It fails with the cache removed.

This branch has not been deployed

No deployments
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.

1 participant