Skip to content

perf(backend): avoid a second JSON encoder walk - #14013

Draft
dexhunter wants to merge 2 commits into
Significant-Gravitas:devfrom
dexhunter:perf/sanitize-json-single-pass
Draft

perf(backend): avoid a second JSON encoder walk#14013
dexhunter wants to merge 2 commits into
Significant-Gravitas:devfrom
dexhunter:perf/sanitize-json-single-pass

Conversation

@dexhunter

Copy link
Copy Markdown
Contributor

Why / What / How

sanitize_json runs jsonable_encoder over the payload twice:

basic_result = to_dict(data)
return to_dict(basic_result, custom_encoder={str: sanitize_string})

The first call is the one that does the work. Pydantic models, dataclasses,
Enum, datetime, Decimal, UUID, PurePath, bytes, sets and tuples all
become 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_string runs on each string.

Counted over the 678 node input_default and metadata values checked into
autogpt_platform/backend/agents/*.json, which is what SafeJson is handed at
data/graph.py:1848-1849, that second call costs 12,376 jsonable_encoder
invocations per pass against the 6,188 a single walk needs
, exactly 2x.

This replaces the second to_dict call with _sanitize_encoded, a walk over
the plain structure that only strips control characters. Two smaller changes go
with it: sanitize_string checks whether there is anything to strip before
substituting, and the compiled pattern's search/sub are bound to module
names so the walk skips two attribute lookups per string.

The search check is worth stating, because it looks redundant. On a miss
re.sub returns the string it was given rather than a copy, so both forms scan
once, but the search skips the substitution machinery. It matters because
almost no strings contain a control character. str.translate was the obvious
alternative 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. upstream is this branch's merge base.

payload upstream this branch
the 678 shipped node input/metadata values 7.683 ms 4.875 ms 1.58x
block output, 40-record list, 10,111 bytes 0.363 ms 0.245 ms 1.48x
block output, 40-record list, 100,441 bytes 3.539 ms 2.441 ms 1.45x
block output, 40-record list, 502,146 bytes 17.500 ms 12.121 ms 1.44x
block output, one string, 10,016 bytes 0.040 ms 0.039 ms 1.03x
block output, one string, 100,016 bytes 0.372 ms 0.365 ms 1.02x
block output, one string, 500,016 bytes 1.839 ms 1.832 ms 1.00x

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_input and
upsert_execution_output mostly 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

SafeJson has 73 non-test call sites. Two are per-node-execution:
data/execution.py:1010 in upsert_execution_input, once per node input, and
:1059 in upsert_execution_output, once per node output. Both execute inside
the shared DatabaseManager service, and sanitize_json is a synchronous
def reached from async endpoints there, so the cost is on that service's
throughput as well as on individual write latency. Nothing truncates before
these writes; truncate() runs on the event-publish path at
data/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 the
    output of to_dict that strips control characters without repeating the type
    conversion, and used it in place of the second to_dict call.
  • backend/util/json.py: sanitize_string returns its argument unchanged when
    the pattern does not match. Also used by backend/copilot/db.py on chat
    message content, so that path benefits too.
  • backend/util/json.py: bound POSTGRES_CONTROL_CHARS.search and .sub to
    module-level names.

Behaviour is unchanged, including the parts that are easy to lose:
jsonable_encoder drops string keys beginning with _sa, which the first pass
still does; dict keys are sanitized as well as values; True stays a bool
and not 1; a str subclass comes back as a plain str whether or not it
contained anything to strip; and the except branch and its return value are
untouched.

Test plan

  • poetry run pytest backend/util/test_json.py — 32 passed, before and
    after the change
  • backend/util/type_test.py — 51 passed
  • Differential check against the unmodified module over an adversarial
    battery plus randomly generated payloads: _sa-prefixed keys, non-string
    dict keys, NaN/Inf, bool-versus-int, str subclasses with and
    without 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 make
    jsonable_encoder raise so the except branch runs. Results compared
    with type-strict equality, since True == 1 and 1 == 1.0.
  • Checked that the returned structure shares no dict or list object
    with the argument, so mutating the argument afterwards cannot change a
    value already handed to Prisma
  • black, isort --profile black, ruff check clean on the changed file
  • pyright reports 0 errors and 0 warnings on the changed file at
    --pythonversion 3.11, 3.12 and 3.13

Checklist 📋

For code changes:

  • I have clearly listed my changes in the PR description
  • I have made a test plan
  • I have tested my changes according to the test plan

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 assisted
with 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 on
different call paths and only overlap in the import block. Happy to rebase
whichever lands second.

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>
@github-project-automation github-project-automation Bot moved this to 🆕 Needs initial review in AutoGPT development kanban Aug 13, 2026
@github-actions github-actions Bot added the platform/backend AutoGPT Platform - Back end label Aug 13, 2026
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 52352924-7f80-4355-b8da-40850a4da7e0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added cla: pending CLA not yet signed by all contributors size/m cla: signed CLA signed by all contributors and removed cla: pending CLA not yet signed by all contributors labels Aug 13, 2026
@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.51%. Comparing base (dba6309) to head (912223b).

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     
Flag Coverage Δ
platform-backend 84.21% <100.00%> (-0.03%) ⬇️
platform-frontend-e2e 30.00% <ø> (-0.46%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
Platform Backend 84.21% <100.00%> (-0.03%) ⬇️
Platform Frontend 56.48% <ø> (-0.14%) ⬇️
AutoGPT Libs ∅ <ø> (∅)
Classic AutoGPT 28.43% <ø> (ø)
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Exercise clean and control-character strings, string subclasses, sanitized keys, nested lists, and rebuilt containers.

Co-Authored-By: Aiden <aiden@weco.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla: signed CLA signed by all contributors platform/backend AutoGPT Platform - Back end size/m

Projects

Status: 🆕 Needs initial review

Development

Successfully merging this pull request may close these issues.

1 participant