perf(backend): avoid a second JSON encoder walk - #14013
Conversation
The first encoder pass already converts complex inputs to plain JSON types. Walk that encoded tree directly to sanitize strings instead of repeating the full encoder type dispatch. Co-Authored-By: Aiden <aiden@weco.ai>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## dev #14013 +/- ##
==========================================
- Coverage 78.55% 78.51% -0.05%
==========================================
Files 2986 2986
Lines 228847 228876 +29
Branches 21532 21536 +4
==========================================
- Hits 179771 179699 -72
- Misses 44186 44281 +95
- Partials 4890 4896 +6
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Exercise clean and control-character strings, string subclasses, sanitized keys, nested lists, and rebuilt containers. Co-Authored-By: Aiden <aiden@weco.ai>
Why / What / How
sanitize_jsonrunsjsonable_encoderover the payload twice:The first call is the one that does the work. Pydantic models, dataclasses,
Enum,datetime,Decimal,UUID,PurePath,bytes, sets and tuples allbecome plain JSON types. The second call walks that already-plain result again,
redoing the full type dispatch on every node, and the only thing it changes is
that
sanitize_stringruns on each string.Counted over the 678 node
input_defaultandmetadatavalues checked intoautogpt_platform/backend/agents/*.json, which is whatSafeJsonis handed atdata/graph.py:1848-1849, that second call costs 12,376jsonable_encoderinvocations per pass against the 6,188 a single walk needs, exactly 2x.
This replaces the second
to_dictcall with_sanitize_encoded, a walk overthe plain structure that only strips control characters. Two smaller changes go
with it:
sanitize_stringchecks whether there is anything to strip beforesubstituting, and the compiled pattern's
search/subare bound to modulenames so the walk skips two attribute lookups per string.
The
searchcheck is worth stating, because it looks redundant. On a missre.subreturns the string it was given rather than a copy, so both forms scanonce, but the search skips the substitution machinery. It matters because
almost no strings contain a control character.
str.translatewas the obviousalternative and it is slower here: it allocates a new string every time, and
across thousands of short clean strings that costs more than it saves. I
measured it at 18.7 ms against 15.8 ms for the unchanged code.
Measurements
Wall time, median of 9 repetitions, on an otherwise idle host under an
exclusive CPU lock.
upstreamis this branch's merge base.Per call over the shipped values that is 0.0113 ms down to 0.0072 ms.
There is no improvement on a payload that is one large string, and the last
three rows are the honest version of that. A single string has almost no tree
to walk, so both versions spend their time in the same regex scan. The win is
on structured payloads, which is what
upsert_execution_inputandupsert_execution_outputmostly store.Instruction counts, measured with Callgrind because the machine was shared,
over a fixed 224-payload corpus: 189.24 M down to 134.6 M retired
instructions per pass, a 1.41x reduction. Three repetitions of the patched
code gave 134.5925 / 134.6500 / 134.6636, a spread of 0.053%.
Where this runs
SafeJsonhas 73 non-test call sites. Two are per-node-execution:data/execution.py:1010inupsert_execution_input, once per node input, and:1059inupsert_execution_output, once per node output. Both execute insidethe shared
DatabaseManagerservice, andsanitize_jsonis a synchronousdefreached fromasyncendpoints there, so the cost is on that service'sthroughput as well as on individual write latency. Nothing truncates before
these writes;
truncate()runs on the event-publish path atdata/execution.py:1551-1555.I want to be plain about the size of this. Per call on the small constant
inputs the saving is 4 microseconds, in front of a Postgres insert. The
argument for the change is the large end and the shared service, not the small
end.
Changes 🏗️
backend/util/json.py: added_sanitize_encoded, a recursive walk over theoutput of
to_dictthat strips control characters without repeating the typeconversion, and used it in place of the second
to_dictcall.backend/util/json.py:sanitize_stringreturns its argument unchanged whenthe pattern does not match. Also used by
backend/copilot/db.pyon chatmessage content, so that path benefits too.
backend/util/json.py: boundPOSTGRES_CONTROL_CHARS.searchand.subtomodule-level names.
Behaviour is unchanged, including the parts that are easy to lose:
jsonable_encoderdrops string keys beginning with_sa, which the first passstill does; dict keys are sanitized as well as values;
Truestays abooland not
1; astrsubclass comes back as a plainstrwhether or not itcontained anything to strip; and the
exceptbranch and its return value areuntouched.
Test plan
poetry run pytest backend/util/test_json.py— 32 passed, before andafter the change
backend/util/type_test.py— 51 passedbattery plus randomly generated payloads:
_sa-prefixed keys, non-stringdict keys,
NaN/Inf, bool-versus-int,strsubclasses with andwithout control characters, control characters in keys, nested Pydantic
models, dataclasses,
Enum,datetime,Decimal,UUID,PurePath,bytes, sets, tuples, deques, 40-deep nesting, and the inputs that makejsonable_encoderraise so theexceptbranch runs. Results comparedwith type-strict equality, since
True == 1and1 == 1.0.dictorlistobjectwith the argument, so mutating the argument afterwards cannot change a
value already handed to Prisma
black,isort --profile black,ruff checkclean on the changed filepyrightreports 0 errors and 0 warnings on the changed file at--pythonversion3.11, 3.12 and 3.13Checklist 📋
For code changes:
AI and optimization assistance
Weco, using Gemini, generated and evaluated candidate variants. I reviewed the
trajectory, selected the smaller step-9 mechanism, adapted it for readability,
and independently replayed the submitted diff on current
dev. Codex assistedwith current-head revalidation and PR preparation. This is not a raw generated
candidate.
The anonymous Weco trajectory is at
https://dashboard.weco.ai/share/6aaQwzAQFB3kygmRM90TnFPSfmMHEbrq — 13 valid
scalar records, most worse than the baseline. The submitted patch is a simpler
variant of the best step: that one reached 132.23 M instructions against this
branch's 134.6 M, a further 1.8%, by inlining string handling into the dict and
list comprehensions, which was not worth the readability cost.
This touches the same file as #13749, which caches the jsonschema validator in
validate_with_jsonschema. The two changes are in different functions ondifferent call paths and only overlap in the import block. Happy to rebase
whichever lands second.