perf(insights): serve cached insight results as raw JSON without parse round trip - #71268
Conversation
…e round trip Cached query responses were stored as one JSON blob, so every dashboard/insight GET paid orjson.loads on the full results payload, pydantic re-validation, and a re-serialization at render time — the dominant CPU cost on large cached dashboards. Store cached responses in a split format (header JSON + results JSON segments, legacy blobs stay readable) and, when the insight serializer opts in, validate only the header and pass the results segment through to the response as an orjson.Fragment. The raw path is gated to orjson renderers (SafeJSONRenderer / the SSE tile stream), skipped when pretty-printing, when the shared-page template needs parsed data (stdlib json.dumps), when run_insights formats results for LLM output, when the response class types results as models, and when either the query or the cached results carry custom series names (which require patching parsed results). Split-format writes are gated behind QUERY_CACHE_SPLIT_FORMAT_WRITES (default off) so every deploy ships split-capable readers before any pod writes the new format; old readers treat split entries as cache misses and recompute. Extracted from #70967 (final of three independent optimizations; the other two landed in #71051 and #71022). Generated-By: PostHog Code Task-Id: 09583179-7dff-4548-8303-953631e1be33
|
Reviews (1): Last reviewed commit: "perf(insights): serve cached insight res..." | Re-trigger Greptile |
🤖 CI report
|
| File | Patch | Uncovered changed lines |
|---|---|---|
posthog/hogql_queries/query_cache_base.py |
33.3% | 112–115 |
products/dashboards/backend/api/dashboard.py |
50.0% | 2347 |
posthog/caching/fetch_from_cache.py |
90.7% | 46, 56, 126–128 |
posthog/hogql_queries/query_runner.py |
92.3% | 1424, 1522 |
🤖 Agents: add a test covering the lines above, or note why under "How did you test this code?". Machine-readable gap list: the patch-coverage artifact on this run (gh run download 29495315747 -n patch-coverage), or the coverage-data block at the end of this comment.
Per-product line coverage (touched products)
| Product | Coverage | Lines |
|---|---|---|
platform_features |
██░░░░░░░░░░░░░░░░░░ 12.1% |
7 / 58 |
batch_exports |
████████░░░░░░░░░░░░ 39.7% |
8,414 / 21,220 |
demo |
███████████░░░░░░░░░ 56.2% |
1,497 / 2,663 |
warehouse_sources_queue |
████████████░░░░░░░░ 59.2% |
148 / 250 |
tasks |
█████████████░░░░░░░ 67.4% |
25,805 / 38,278 |
data_tools |
██████████████░░░░░░ 70.0% |
63 / 90 |
ai_gateway |
███████████████░░░░░ 75.0% |
9 / 12 |
signals |
████████████████░░░░ 79.1% |
19,033 / 24,073 |
data_modeling |
████████████████░░░░ 80.0% |
4,834 / 6,045 |
cdp |
████████████████░░░░ 80.7% |
3,118 / 3,864 |
wizard |
████████████████░░░░ 82.5% |
772 / 936 |
notebooks |
█████████████████░░░ 84.3% |
6,343 / 7,520 |
agent_platform |
█████████████████░░░ 84.7% |
3,273 / 3,862 |
cohorts |
█████████████████░░░ 86.1% |
4,022 / 4,671 |
actions |
█████████████████░░░ 86.6% |
717 / 828 |
product_tours |
█████████████████░░░ 87.5% |
1,266 / 1,447 |
exports |
██████████████████░░ 88.3% |
6,891 / 7,800 |
visual_review |
██████████████████░░ 88.5% |
5,565 / 6,287 |
business_knowledge |
██████████████████░░ 88.5% |
4,400 / 4,969 |
conversations |
██████████████████░░ 88.9% |
16,129 / 18,133 |
mcp_analytics |
██████████████████░░ 89.1% |
2,502 / 2,807 |
dashboards |
██████████████████░░ 89.2% |
5,726 / 6,419 |
error_tracking |
██████████████████░░ 89.6% |
9,738 / 10,872 |
alerts |
██████████████████░░ 89.9% |
3,638 / 4,046 |
engineering_analytics |
██████████████████░░ 90.1% |
5,105 / 5,665 |
streamlit_apps |
██████████████████░░ 90.4% |
2,499 / 2,764 |
slack_app |
██████████████████░░ 90.6% |
9,511 / 10,503 |
links |
██████████████████░░ 90.6% |
183 / 202 |
marketing_analytics |
██████████████████░░ 90.8% |
11,514 / 12,684 |
product_analytics |
██████████████████░░ 91.2% |
5,606 / 6,150 |
mcp_store |
██████████████████░░ 91.8% |
3,668 / 3,995 |
managed_migrations |
██████████████████░░ 91.9% |
908 / 988 |
data_warehouse |
██████████████████░░ 92.1% |
18,133 / 19,683 |
workflows |
██████████████████░░ 92.4% |
5,148 / 5,574 |
notifications |
███████████████████░ 92.7% |
1,026 / 1,107 |
web_analytics |
███████████████████░ 92.7% |
13,624 / 14,691 |
ai_observability |
███████████████████░ 92.8% |
14,868 / 16,019 |
surveys |
███████████████████░ 92.9% |
5,687 / 6,120 |
posthog_ai |
███████████████████░ 93.2% |
1,322 / 1,418 |
tracing |
███████████████████░ 93.3% |
2,435 / 2,611 |
approvals |
███████████████████░ 93.3% |
3,395 / 3,640 |
reminders |
███████████████████░ 93.4% |
468 / 501 |
early_access_features |
███████████████████░ 93.8% |
848 / 904 |
legal_documents |
███████████████████░ 94.1% |
1,568 / 1,667 |
endpoints |
███████████████████░ 94.1% |
8,606 / 9,143 |
skills |
███████████████████░ 94.4% |
2,827 / 2,995 |
revenue_analytics |
███████████████████░ 94.5% |
3,598 / 3,809 |
messaging |
███████████████████░ 94.5% |
2,530 / 2,677 |
review_hog |
███████████████████░ 94.5% |
6,557 / 6,935 |
logs |
███████████████████░ 95.3% |
9,528 / 9,994 |
experiments |
███████████████████░ 95.6% |
24,234 / 25,351 |
replay_vision |
███████████████████░ 95.7% |
13,354 / 13,952 |
growth |
███████████████████░ 95.8% |
2,837 / 2,960 |
annotations |
███████████████████░ 96.2% |
732 / 761 |
feature_flags |
███████████████████░ 96.3% |
16,118 / 16,741 |
warehouse_sources |
███████████████████░ 96.3% |
239,624 / 248,804 |
user_interviews |
███████████████████░ 96.4% |
2,242 / 2,325 |
access_control |
███████████████████░ 96.8% |
849 / 877 |
data_catalog |
███████████████████░ 97.1% |
2,034 / 2,095 |
customer_analytics |
███████████████████░ 97.2% |
7,480 / 7,698 |
analytics_platform |
████████████████████ 98.0% |
2,102 / 2,145 |
metrics |
████████████████████ 98.2% |
2,404 / 2,448 |
live_debugger |
████████████████████ 99.2% |
613 / 618 |
field_notes |
████████████████████ 99.4% |
158 / 159 |
Report-only. Patch coverage = changed backend lines covered vs origin/master. Sorted lowest first.
Known gaps: lines covered only by Temporal tests show as uncovered; core line numbers may drift if master changed the same file.
|
Note 🤖 stamphog reviewed A cross-team, unreviewed change to the core query-cache serialization/read path used for every dashboard and insight load — mistakes here risk silently corrupted or malformed results in production. It's carefully feature-flag-gated and tested, but that's the author's own engineering, not independent assurance, and familiarity is only MODERATE (not STRONG) with no team-owner review.
Gate mechanics and policy version
|
Drop the QUERY_CACHE_SPLIT_FORMAT_WRITES rollout gate: split-format cache writes are now always on for list results. Pods that predate the format treat split entries as cache misses during the brief rolling-deploy window and recompute once — an accepted tradeoff since deploys are quick. Generated-By: PostHog Code Task-Id: 53eac902-6054-4508-b0f5-a322309561fd
TL;DR (eli5)
Opening a big dashboard was slow even when every chart was already cached. The server pulled each cached chart out of Redis, unpacked its JSON into Python objects, checked them, then packed them back into JSON to send — three passes for zero change in the output. Now cached results are stored so the server can grab the already-JSON chunk and drop it straight into the response, skipping the unpack/repack. Same bytes out, much faster on large dashboards.
Problem
On large cached dashboard/insight GETs, most wall time is spent outside the database. Cached query responses were stored as a single JSON blob, so every hit paid
orjson.loadson the full results payload, pydantic re-validation, and a re-serialization at render time — the dominant CPU cost when the results are big.This is the last of three independent, behavior-preserving optimizations extracted from #70967. The first two already landed:
model_dump— perf(insights): skip full response model_dump when serving insight results #71051Changes
magic + flags + header JSON + results JSON. Theresultsfield lives in its own segment so a cache hit can skip parsing it. Blobs without the magic prefix are the legacy single-JSON format and stay readable.orjson.Fragment— no parse → validate → re-serialize round trip.SafeJSONRenderer/ the SSE tile stream) and falls back to the parsed path for:?indent=pretty-printing, the shared-page template (stdlibjson.dumps),run_insightsLLM formatting, model-typed result classes, and any query/results carrying custom series names (those need patched parsed results).results; anything else keeps the legacy single-JSON format). Pods that predate this change treat split entries as cache misses and recompute, so entries written during the brief rolling-deploy window may be recomputed once — an accepted tradeoff since deploys are quick. There is no rollout flag.How did you test this code?
Automated tests carried over from the source PR:
posthog/caching/test/test_query_cache_format.py(new): asserts writes round-trip through the split format by default, non-list results fall back to the legacy format, and pre-existing legacy blobs stay readable.posthog/caching/test/test_calculate_results.py: adds a case proving raw cached results surface asorjson.Fragmentand serialize to the expected bytes.products/product_analytics/backend/api/test/test_insight.py: updated mock assertions for the newallow_raw_resultskwarg.In this environment the full Django test suite can't run (no app dependencies installed), so these run in CI. Locally verified:
py_compileandruff checkpass on all changed files, and the split encoder/decoder round-trips (legacy blobs, custom-name flag, header-only fallback).Automatic notifications
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Extracted the third optimization from #70967 by PostHog Code. Since the first two commits of that PR already merged (#71051, #71022) and the source branch is stale relative to master, the change was isolated by diffing the source PR head against current master per file — dropping the model-typed-results fix and
test_calculate_results.pyscaffolding that already shipped with #71051, and keeping only the split-cache-format + raw-fragment pass-through delta. Reviewed each file diff to confirm no unrelated master content was reverted.Created with PostHog Code