[PR #24025] [CLOSED] perf(chats): skip deep copy in sanitize_data_for_db when input has no null bytes or surrogates #66338

Closed
opened 2026-05-06 12:38:35 -05:00 by GiteaMirror · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/open-webui/open-webui/pull/24025
Author: @runixer
Created: 4/23/2026
Status: Closed

Base: devHead: perf/sanitize-data-for-db-fast-path


📝 Commits (1)

  • e5350d7 perf(chats): skip deep copy in sanitize_data_for_db when input has no null bytes or surrogates

📊 Changes

1 file changed (+31 additions, -0 deletions)

View changed files

📝 backend/open_webui/utils/misc.py (+31 -0)

📄 Description

Pull Request Checklist

  • Target branch: dev
  • Description + measurements below
  • Changelog entry at bottom
  • No docs changes (backend-only, no user-facing behaviour)
  • No new or upgraded dependencies
  • Testing: correctness verified by running both implementations against every row (34 780) in our production chat table, 0 mismatches. Patch is running in our production fork since 2026-04-20. Tested on the pre-0.9 (sync) implementation of the caller; the patch itself is caller-agnostic — it modifies a pure-Python helper that sanitizes in-memory structures.
  • Agentic AI Code: change drafted with AI assistance, then human-reviewed, human-tested, and deployed to production. The patch is ~30 lines and the invariant was verified exhaustively.
  • Code review: self-reviewed
  • Git Hygiene: single atomic commit rebased on dev
  • Title prefix: perf

Changelog Entry

  • Zero-copy fast path for sanitize_data_for_db. The chat write path no longer rebuilds the entire chat structure on every call to sanitize_data_for_db. A lightweight scan now checks whether any string actually contains the characters that sanitize_text_for_db would rewrite (NUL or lone UTF-16 surrogates); if not, the input is returned as-is. This eliminates a deep copy of the chat JSON on effectively every real-world write.

Description

sanitize_data_for_db is called on every chat payload before persisting. The existing implementation always rebuilds the whole structure recursively — new dicts, new lists, re-sanitized strings — even when the input is already clean. That allocates a parallel copy of the entire chat tree, which becomes tens of MB on long RAG-heavy conversations.

In practice the characters that sanitize_text_for_db rewrites (NUL and lone UTF-16 surrogate code points) cannot be stored in PostgreSQL text or jsonb columns, so data read back from the database is always clean. The only path that produces dirty input is inbound untrusted content on ingest, which is extremely rare.

This PR adds a _sanitize_needs_copy helper that scans for exactly the code points sanitize_text_for_db would modify. If nothing matches, sanitize_data_for_db returns the input object as-is (zero copy). If anything matches, it falls back to the existing recursive-copy path. The behaviour is semantically identical for all inputs.

Measurements on real data

Against a 34 780-row production chat table (with pg_column_size(chat) up to 80 MB):

Shape Leaves old time new time old peak alloc new peak alloc
Active chat, 28 MB on disk (274k leaves, avg 154 chars) 274,817 394 ms 217 ms 96 MB 0 MB
Active chat, 9 MB (131k leaves) 131,185 109 ms 95 ms 34 MB 0 MB
Active chat, 2 MB (7.7k leaves) 7,667 24 ms 14 ms ~8 MB 0 MB
Pathological: 80 MB on disk, 230 huge leaves 230 103 ms 220 ms 90 MB 0 MB

Typical chats (many short leaves, deep nesting) are 1.5–1.8× faster and allocate nothing on sanitize. Pathological shapes with a handful of huge leaf strings get slower CPU-wise (regex scan vs C-level str.replace + encode/decode) but still save on memory.

Correctness

Invariant: new_sanitize(x) == old_sanitize(x) for every x.

Verified by running both implementations against every chat in our production database (34,780 rows) and deep-comparing outputs. 0 mismatches. 99.98% of chats took the fast path; the 7 that fell through to the slow path all contained legacy NUL bytes (binary pasted into messages, or ZIP bytes from pre-Tika document uploads before sanitize_data_for_db was introduced in #20072) and produced identical results in both implementations.

Testing

Running in our production fork since 2026-04-20. The patch only changes the pre-check and preserves the original recursive path verbatim, so behaviour for any dirty input is unchanged.

Contributor License Agreement


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/open-webui/open-webui/pull/24025 **Author:** [@runixer](https://github.com/runixer) **Created:** 4/23/2026 **Status:** ❌ Closed **Base:** `dev` ← **Head:** `perf/sanitize-data-for-db-fast-path` --- ### 📝 Commits (1) - [`e5350d7`](https://github.com/open-webui/open-webui/commit/e5350d73ae35ade552b47c1cf7b8223aaf22a244) perf(chats): skip deep copy in sanitize_data_for_db when input has no null bytes or surrogates ### 📊 Changes **1 file changed** (+31 additions, -0 deletions) <details> <summary>View changed files</summary> 📝 `backend/open_webui/utils/misc.py` (+31 -0) </details> ### 📄 Description # Pull Request Checklist - [x] Target branch: `dev` - [x] Description + measurements below - [x] Changelog entry at bottom - [x] No docs changes (backend-only, no user-facing behaviour) - [x] No new or upgraded dependencies - [x] Testing: correctness verified by running both implementations against every row (34 780) in our production chat table, 0 mismatches. Patch is running in our production fork since 2026-04-20. Tested on the pre-0.9 (sync) implementation of the caller; the patch itself is caller-agnostic — it modifies a pure-Python helper that sanitizes in-memory structures. - [x] Agentic AI Code: change drafted with AI assistance, then human-reviewed, human-tested, and deployed to production. The patch is ~30 lines and the invariant was verified exhaustively. - [x] Code review: self-reviewed - [x] Git Hygiene: single atomic commit rebased on `dev` - [x] Title prefix: **perf** # Changelog Entry - ⚡ **Zero-copy fast path for `sanitize_data_for_db`.** The chat write path no longer rebuilds the entire chat structure on every call to `sanitize_data_for_db`. A lightweight scan now checks whether any string actually contains the characters that `sanitize_text_for_db` would rewrite (NUL or lone UTF-16 surrogates); if not, the input is returned as-is. This eliminates a deep copy of the chat JSON on effectively every real-world write. ### Description `sanitize_data_for_db` is called on every chat payload before persisting. The existing implementation always rebuilds the whole structure recursively — new dicts, new lists, re-sanitized strings — even when the input is already clean. That allocates a parallel copy of the entire chat tree, which becomes tens of MB on long RAG-heavy conversations. In practice the characters that `sanitize_text_for_db` rewrites (NUL and lone UTF-16 surrogate code points) *cannot* be stored in PostgreSQL `text` or `jsonb` columns, so data read back from the database is always clean. The only path that produces dirty input is inbound untrusted content on ingest, which is extremely rare. This PR adds a `_sanitize_needs_copy` helper that scans for exactly the code points `sanitize_text_for_db` would modify. If nothing matches, `sanitize_data_for_db` returns the input object as-is (zero copy). If anything matches, it falls back to the existing recursive-copy path. The behaviour is semantically identical for all inputs. ### Measurements on real data Against a 34 780-row production chat table (with `pg_column_size(chat)` up to 80 MB): | Shape | Leaves | old time | new time | old peak alloc | new peak alloc | |---|---|---|---|---|---| | Active chat, 28 MB on disk (274k leaves, avg 154 chars) | 274,817 | 394 ms | 217 ms | 96 MB | 0 MB | | Active chat, 9 MB (131k leaves) | 131,185 | 109 ms | 95 ms | 34 MB | 0 MB | | Active chat, 2 MB (7.7k leaves) | 7,667 | 24 ms | 14 ms | ~8 MB | 0 MB | | Pathological: 80 MB on disk, 230 huge leaves | 230 | 103 ms | 220 ms | 90 MB | 0 MB | Typical chats (many short leaves, deep nesting) are 1.5–1.8× faster and allocate nothing on sanitize. Pathological shapes with a handful of huge leaf strings get slower CPU-wise (regex scan vs C-level `str.replace` + `encode`/`decode`) but still save on memory. ### Correctness Invariant: `new_sanitize(x) == old_sanitize(x)` for every `x`. Verified by running both implementations against every chat in our production database (34,780 rows) and deep-comparing outputs. **0 mismatches.** 99.98% of chats took the fast path; the 7 that fell through to the slow path all contained legacy NUL bytes (binary pasted into messages, or ZIP bytes from pre-Tika document uploads before `sanitize_data_for_db` was introduced in #20072) and produced identical results in both implementations. ### Testing Running in our production fork since 2026-04-20. The patch only changes the pre-check and preserves the original recursive path verbatim, so behaviour for any dirty input is unchanged. ### Contributor License Agreement - [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
GiteaMirror added the pull-request label 2026-05-06 12:38:35 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/open-webui#66338