Skip to content

perf(ai): resolve the profile_list page in two stages - #761

Merged
izadoesdev merged 3 commits into
mainfrom
staging
Sep 8, 2026
Merged

perf(ai): resolve the profile_list page in two stages#761
izadoesdev merged 3 commits into
mainfrom
staging

Conversation

@izadoesdev

@izadoesdev izadoesdev commented Sep 8, 2026

Copy link
Copy Markdown
Member

1072ms → 554ms (1.94x), byte-identical output.

The problem

profile_list read 3,324,273 rows and 361 MiB to return 50 rows.

visitor_profiles is only all_visitor_profiles ORDER BY last_visit LIMIT 50, but it was referenced four times as visitor_id IN (SELECT visitor_id FROM visitor_profiles) — inside visitor_custom_events, profile_payment_intents, profile_payment_context and visitor_revenue. ClickHouse inlines a CTE at every reference, so each of those re-ran the whole pipeline just to recover 50 ids. That drove visitor_identity_rows to 26 executions and the query to 75 base-table scans.

The change

prepareSql is a new optional builder hook. It resolves the key set in its own round trip, and customSql binds it as preparedKeys, turning the four semi-joins into a parameter.

The subquery form is kept as the fallback whenever no keys are supplied, so compile() on its own still emits the original single query. That is deliberate: it is what makes the two forms directly comparable.

Builders declaring prepareSql get a unique batch group so they always run through execute() rather than being folded into a UNION, since a staged builder needs its own round trip.

Verification

single-query 1266, 977, 974 ms — avg 1072
two-stage 711, 517, 434 ms — avg 554

Output compared against the single-query form executed in the same instant on a closed date range. All 50 rows and 17 columns identical.

That detail matters: my first comparison reported a difference, but the baseline was 30 minutes stale and the query orders by last_visit DESC, so live events had reshuffled the top rows. Comparing stale output against a live query is not a valid check.

Batching verified too — two profile_list requests plus a top_pages in one batch resolved with batch_union_groups: 0. 673 tests pass, typecheck and lint clean.

Also carries #759 and #760, already reviewed.


Summary by cubic

Resolves the profile_list query in two stages, cutting average runtime from 1072ms to 554ms with byte-identical output. The new optional prepareSql builder hook fetches the visitor key set in its own round trip, and customSql binds it as preparedKeys so ClickHouse stops re-running the CTE at every reference.

Query builder

  • Builders declaring prepareSql get a unique batch group so they always run through execute() instead of being folded into a UNION.
  • Without prepared keys, compile() still emits the original subquery form, keeping the two forms directly comparable.

Behavior changes

  • The discovery tool now accepts null category and search to search across all categories or list a compact catalog without output fields.
  • Goal and funnel tools now distinguish savedDefinition from read-time cohort filters.
  • Agent guidance now favors stopping reads once a decision is supported and retaining independent business changes such as falling attribution alongside stable gross.
  • Verification comparisons ignore null cohort values.

Written for commit 4e5de43. Summary will update on new commits.

Review in cubic

* fix(insights): verify unfiltered native analytics reads

* test(insights): match source fixture to supported read windows
profile_list read 3,324,273 rows and 361 MiB to return 50. visitor_profiles
is only all_visitor_profiles ordered by last_visit with a limit, but it was
referenced four times as visitor_id IN (SELECT visitor_id FROM
visitor_profiles), inside visitor_custom_events, profile_payment_intents,
profile_payment_context and visitor_revenue. ClickHouse inlines a CTE at
every reference, so each of those re-ran the whole pipeline to recover 50
ids, driving visitor_identity_rows to 26 executions and 75 base table scans.

prepareSql is a new optional builder hook. It resolves that key set in its
own round trip and customSql binds it as preparedKeys, so the semi-joins
become a parameter instead of a repeated subquery. The subquery form is
kept as the fallback whenever no keys are supplied, which is why compile()
alone still produces the original query.

Builders declaring prepareSql are given a unique batch group so they always
run through execute() rather than being folded into a UNION, since a staged
builder needs its own round trip.

Measured on the busiest website over a closed date range, three runs each:
1072ms to 554ms, a 1.94x improvement. Output is byte identical, compared
against the single-query form executed in the same instant to rule out
drift from live data. 673 tests pass and two batched profile_list requests
plus a top_pages request each resolve correctly.
…ery (#759)

* feat(insights): retain independent business changes and simplify discovery

* test(insights): preserve compact discovery in holdouts

* fix(ai): distinguish saved definitions from measured cohorts
@vercel

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
dashboard (staging) Ready Ready Preview Sep 8, 2026 3:16pm UTC
databuddy-status Ready Ready Preview Sep 8, 2026 3:16pm UTC
documentation (staging) Ready Ready Preview Sep 8, 2026 3:16pm UTC

@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. 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: Repository UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 11d4472f-a49f-43dd-8ffe-ee9e850ffb4a

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.

@greptile-apps

greptile-apps Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces optional two-stage query execution for profile_list, prevents staged builders from entering batch UNIONs, and expands AI investigation guidance and evaluation coverage.

  • Resolves profile IDs in a preparation query and binds them into enrichment predicates.
  • Adds compact cross-category query discovery and related holdout evaluations.
  • Clarifies goal/funnel saved-definition and cohort semantics.
  • The staged profile flow currently permits inconsistent results when live data changes between its two independent reads.

Confidence Score: 4/5

The PR is not safe to merge until the two-stage profile query prevents live updates from producing profiles with incorrectly zeroed enrichment.

The preparation and full queries independently calculate the requested profile page, while only the first page’s IDs control enrichment; a page change between requests therefore returns materially incorrect custom-event and revenue fields.

Files Needing Attention: packages/ai/src/query/simple-builder.ts, packages/ai/src/query/builders/profiles.ts

Important Files Changed

Filename Overview
packages/ai/src/query/simple-builder.ts Adds generic staged query execution, but the two independent reads do not share consistent data.
packages/ai/src/query/builders/profiles.ts Splits profile-list ID selection from enrichment while recomputing the final profile page.
packages/ai/src/query/batch-executor.ts Correctly isolates staged builders into singleton groups that execute outside batch UNIONs.
packages/ai/src/query/types.ts Adds the optional preparation-hook contract and prepared-key context.
packages/ai/src/ai/tools/discover-query-types.ts Supports explicit cross-category discovery and compact unfiltered catalog responses.
apps/insights/src/evals/quality.ts Adds revenue-depth and cross-category discovery holdouts plus accepted-finish inspection.

Sequence Diagram

sequenceDiagram
  participant B as SimpleQueryBuilder
  participant C as ClickHouse
  B->>C: Prepare query: resolve visitor IDs
  C-->>B: preparedKeys
  Note over C: Live events may change profile ordering
  B->>C: Full query: recompute visitor_profiles
  Note over B,C: Enrichment CTEs still filter by earlier preparedKeys
  C-->>B: Current profile page with potentially zeroed enrichment
Loading

Reviews (1): Last reviewed commit: "feat(insights): retain business changes ..." | Re-trigger Greptile

Comment on lines +1204 to +1205
const preparedKeys = await this.resolvePreparedKeys(abortSignal);
const { sql, params } = this.compile(preparedKeys);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Profile Enrichment Can Drift

If live activity changes the requested profile page between the prepare query and the full query, the full query recomputes visitor_profiles but restricts its enrichment CTEs to IDs from the earlier read. A profile that enters the page during that interval is therefore returned with its custom-event counts and LTV incorrectly set to zero. The two stages need to use a consistent key set or equivalent snapshot semantics.

@izadoesdev
izadoesdev merged commit 574139a into main Sep 8, 2026
28 checks passed
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