Repository navigation
fix(insights): keep parallel insight series in one trace #88545
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
351484f
fix(insights): keep parallel insight series in one trace
pauldambra 86242ca
fix: satisfy mypy union-attr check in test_parallel.py
pauldambra 22d5f2e
fix(insights): narrow trace context fix to trends and funnels
pauldambra 7ec0067
Merge branch 'master' into posthog/fix-parallel-insight-trace-context
pauldambra f760d75
fix(insights): harden run_in_parallel_threads against future misuse
pauldambra 86bc3e6
fix(insights): close two theoretical gaps in run_in_parallel_threads
pauldambra 3274beb
Merge branch 'master' of https://github.com/PostHog/posthog into past…
pauldambra b3dcfd8
Merge remote-tracking branch 'origin/master' into pastori/88545
pauldambra b143d0c
Merge remote-tracking branch 'origin/master' into pastori/88545
pauldambra 013e3b9
Merge branch 'master' of https://github.com/PostHog/posthog into past…
pauldambra 524c744
Merge remote-tracking branch 'origin/master' into pastori/88545
pauldambra 3807bc6
Merge branch 'master' into pastori/88545
claude 6c89105
Merge remote-tracking branch 'origin/master' into pastori/88545-t1789…
pauldambra File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
52 changes: 52 additions & 0 deletions
52
.semgrep/rules/devex/query-runners-propagate-thread-context.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| rules: | ||
| - id: query-runners-propagate-thread-context | ||
| message: | | ||
| Bare `threading.Thread` in a query runner. A new thread starts with empty ContextVars, so | ||
| the worker loses everything the query path reads from context: the OpenTelemetry span | ||
| context, the ClickHouse query tags, and the warehouse-warning accumulator. Losing the span | ||
| context means each worker opens its spans with no parent, so one parallel insight reports | ||
| a separate trace per series instead of a single request trace, and the request trace shows | ||
| an unexplained gap where the query work should be. Losing the query tags means the | ||
| worker's ClickHouse queries land in `system.query_log` with no product or feature | ||
| attribution. A raw thread per work item is also unbounded, so one request can take a web | ||
| worker's threads and a large share of the shared Postgres connection pool. | ||
|
|
||
| Use `run_in_parallel_threads` from `posthog.hogql_queries.utils.parallel`, which runs the | ||
| work in a bounded pool and copies the caller's context into every worker. Pass thunks | ||
| (`functools.partial`) instead of `target=`/`args=`: | ||
|
|
||
| run_in_parallel_threads([partial(run, index, query) for index, query in enumerate(queries)]) | ||
|
|
||
| Use `map_in_caller_context` from `posthog.hogql_queries.utils.caller_context` instead when | ||
| the work returns values rather than writing into a shared result list. It runs serially | ||
| under `TEST`, so a test that must reach the pool cannot use it. | ||
|
|
||
| A hand-rolled `ThreadPoolExecutor` loses the context the same way, and this rule does not | ||
| detect that form. Submit a fresh `contextvars.copy_context().run` per task, as | ||
| `posthog/hogql_queries/utils/timestamp_utils.py` does. One `Context` cannot be entered | ||
| twice, so hoisting a single `copy_context()` above the loop raises `RuntimeError` on the | ||
| second task. | ||
|
|
||
| For a thread that genuinely must not inherit the caller's context, add | ||
| `# nosemgrep: query-runners-propagate-thread-context` with a one-line why. | ||
|
|
||
| Backlog: `products/experiments/backend/hogql_queries/experiment_trends_query_runner.py` | ||
| still spawns bare threads. Converting it changes which ClickHouse cluster and user its | ||
| subqueries use, and starts metering their scanned bytes, because that runner propagates no | ||
| tags today. That needs its own change, so the rule stays at WARNING until then. The | ||
| new-warnings CI pass still blocks any bare thread a PR adds. | ||
| severity: WARNING | ||
| languages: [python] | ||
| # Anchored at the repo root, which is where CI scans from. The leading slash also opts out of | ||
| # semgrep's Semgrepignore v2 deprecation warning for unanchored patterns. | ||
| paths: | ||
| include: | ||
| - '/posthog/hogql_queries/**' | ||
| - '/products/*/backend/hogql_queries/**' | ||
| pattern-either: | ||
| - pattern: threading.Thread(...) | ||
| - pattern: Thread(...) | ||
| metadata: | ||
| category: best-practice | ||
| subcategory: | ||
| - audit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| """Run query-runner work concurrently without dropping the caller's ambient context. | ||
|
|
||
| A bare `threading.Thread` starts with empty ContextVars, so everything the query path reads from | ||
| context is absent in the worker: the OpenTelemetry span context, the ClickHouse query tags, and the | ||
| warehouse-warning accumulator. Without the span context a worker opens its spans with no parent, so | ||
| one parallel insight reports several disconnected traces instead of a single request trace. | ||
|
|
||
| `ThreadPoolExecutor` drops the context the same way, so submitting a bare callable to a pool has | ||
| the same defect as the bare thread it replaces. | ||
| """ | ||
|
|
||
| import contextvars | ||
| from collections.abc import Callable, Sequence | ||
| from concurrent.futures import ThreadPoolExecutor | ||
|
|
||
| import structlog | ||
|
|
||
| from posthog.hogql.constants import INSIGHT_QUERY_FANOUT_CONCURRENCY | ||
|
|
||
| from posthog.clickhouse.query_tagging import get_query_tags, query_tags | ||
|
|
||
| logger = structlog.get_logger(__name__) | ||
|
|
||
|
|
||
| def run_in_parallel_threads( | ||
| work: Sequence[Callable[[], None]], | ||
| *, | ||
| max_workers: int = INSIGHT_QUERY_FANOUT_CONCURRENCY, | ||
| thread_name_prefix: str = "parallel_work", | ||
| ) -> None: | ||
| """Run the callables in a bounded thread pool, then wait for all of them. | ||
|
|
||
| The pool caps how many run at once. One insight request expands into one query per series, and | ||
| a client controls that expansion, so an unbounded fan-out lets one request take a web worker's | ||
| threads and a large share of the shared Postgres connection pool. | ||
|
|
||
| Every worker gets its own copy of the context mapping, so rebinding a ContextVar in one worker | ||
| cannot reach a sibling or the caller. The copy is shallow, so a value reached through the | ||
| context is still shared, and mutating one in place does reach the caller. | ||
|
|
||
| Warehouse warnings and the query scan accumulator depend on that shallow sharing, because a | ||
| worker contributes to the caller's object. Query tags do not: this helper gives each worker a | ||
| private tags copy up front, so even a worker that mutates its tags in place (skipping | ||
| `tag_queries`, which always replaces the snapshot instead) only ever touches its own copy, | ||
| never a sibling's or the caller's. | ||
|
|
||
| An exception raised by a work item does not escape to the pool's future and vanish unread. It | ||
| is collected and re-raised in the caller once every item has finished, so a raising item cannot | ||
| leave the caller reading a partial result with no signal anything went wrong. If more than one | ||
| item raises, the caller sees whichever exception is appended first, meaning the first item to | ||
| finish raising rather than the first item in `work`, and every other collected exception is | ||
| logged rather than dropped. | ||
| """ | ||
| if not work: | ||
| return | ||
|
|
||
| errors: list[Exception] = [] | ||
|
|
||
| def run_item(item: Callable[[], None]) -> None: | ||
| try: | ||
| # Rebind before running the item, not after: a worker's very first line of code could | ||
| # mutate the shared QueryTags object in place, so the copy has to exist before that. | ||
| query_tags.set(get_query_tags().model_copy()) | ||
| item() | ||
| except Exception as e: | ||
|
pauldambra marked this conversation as resolved.
Resolved
|
||
| errors.append(e) | ||
|
|
||
| with ThreadPoolExecutor(max_workers=min(max_workers, len(work)), thread_name_prefix=thread_name_prefix) as executor: | ||
| for item in work: | ||
| # A fresh Context per item, because one Context cannot be entered twice and a pool | ||
| # thread runs several items in turn. | ||
| executor.submit(contextvars.copy_context().run, run_item, item) | ||
|
|
||
| if errors: | ||
| # Surface every secondary error with its traceback before re-raising the first, so a | ||
| # concurrent failure doesn't disappear into the void. Matches funnels_query_runner's | ||
| # `_run_in_parallel`. | ||
| for dropped in errors[1:]: | ||
| logger.exception("parallel_work_secondary_error", exc_info=dropped) | ||
| raise errors[0] | ||
|
pauldambra marked this conversation as resolved.
Resolved
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| import threading | ||
| import contextvars | ||
| from functools import partial | ||
|
|
||
| import pytest | ||
|
|
||
| from opentelemetry.sdk.trace import TracerProvider | ||
| from opentelemetry.sdk.trace.export import SimpleSpanProcessor | ||
| from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter | ||
|
|
||
| from posthog.clickhouse.query_tagging import get_query_tags, reset_query_tags, tag_queries | ||
| from posthog.hogql_queries.utils.parallel import run_in_parallel_threads | ||
|
|
||
| _probe: contextvars.ContextVar[str] = contextvars.ContextVar("parallel_test_probe", default="unset") | ||
|
|
||
|
|
||
| def test_worker_spans_stay_inside_the_callers_trace() -> None: | ||
| exporter = InMemorySpanExporter() | ||
| provider = TracerProvider() | ||
| provider.add_span_processor(SimpleSpanProcessor(exporter)) | ||
| tracer = provider.get_tracer("test") | ||
|
|
||
| def work(index: int) -> None: | ||
| with tracer.start_as_current_span(f"series_{index}"): | ||
| pass | ||
|
|
||
| with tracer.start_as_current_span("caller") as caller: | ||
| caller_context = caller.get_span_context() | ||
| run_in_parallel_threads([partial(work, index) for index in range(3)]) | ||
|
|
||
| series = [span for span in exporter.get_finished_spans() if span.name.startswith("series_")] | ||
| assert len(series) == 3 | ||
| for span in series: | ||
| # Checked before the span_id read below, so a worker that lost the context reports the | ||
| # missing parent rather than an AttributeError on None. This also narrows the type, which | ||
| # is what the union-attr check needs. | ||
| assert span.parent is not None, f"{span.name} is its own trace root, so the context was lost" | ||
| assert span.parent.span_id == caller_context.span_id | ||
| assert span.context.trace_id == caller_context.trace_id | ||
|
|
||
|
|
||
| def test_workers_read_the_callers_context_and_cannot_rebind_it() -> None: | ||
| token = _probe.set("from_caller") | ||
| seen: list[str] = [] | ||
|
|
||
| def work(index: int) -> None: | ||
| seen.append(_probe.get()) | ||
| _probe.set(f"from_worker_{index}") | ||
|
|
||
| try: | ||
| run_in_parallel_threads([partial(work, index) for index in range(2)]) | ||
|
|
||
| assert seen == ["from_caller", "from_caller"] | ||
| assert _probe.get() == "from_caller" | ||
| finally: | ||
| _probe.reset(token) | ||
|
|
||
|
|
||
| def test_exception_from_a_work_item_is_reraised_to_the_caller() -> None: | ||
| def boom() -> None: | ||
| raise ValueError("boom") | ||
|
|
||
| def noop() -> None: | ||
| pass | ||
|
|
||
| with pytest.raises(ValueError, match="boom"): | ||
| run_in_parallel_threads([noop, boom]) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("item_count,max_workers", [(0, 10), (5, 1), (12, 3)]) | ||
| def test_every_item_runs_on_no_more_threads_than_the_limit(item_count: int, max_workers: int) -> None: | ||
| lock = threading.Lock() | ||
| thread_ids: list[int] = [] | ||
|
|
||
| def work() -> None: | ||
| with lock: | ||
| thread_ids.append(threading.get_ident()) | ||
|
|
||
| run_in_parallel_threads([work] * item_count, max_workers=max_workers) | ||
|
|
||
| assert len(thread_ids) == item_count | ||
| assert len(set(thread_ids)) <= max_workers | ||
|
|
||
|
|
||
| def test_worker_mutating_query_tags_in_place_cannot_reach_a_sibling_or_the_caller() -> None: | ||
| reset_query_tags() | ||
| tag_queries(team_id=1) | ||
| # Forces the read to happen strictly after the mutation, so the assertion is deterministic | ||
| # regardless of thread scheduling: without the per-worker rebind, the mutation below would | ||
| # reach every reader because all workers would still share the caller's QueryTags object. | ||
| mutated = threading.Event() | ||
| seen: list[int | None] = [] | ||
|
|
||
| def mutate_without_tag_queries() -> None: | ||
| # Mutates the object in place instead of going through `tag_queries`, which is exactly | ||
| # the pattern the per-worker rebind in run_in_parallel_threads has to guard against. | ||
| get_query_tags().team_id = 999 | ||
| mutated.set() | ||
|
|
||
| def read_team_id_after_mutation() -> None: | ||
| mutated.wait(timeout=5) | ||
| seen.append(get_query_tags().team_id) | ||
|
|
||
| run_in_parallel_threads([mutate_without_tag_queries, read_team_id_after_mutation]) | ||
|
|
||
| assert seen == [1] | ||
| assert get_query_tags().team_id == 1 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note
🤖 Automated comment by QA Swarm — not written by a human
[convergent: general (sonnet) + code-reviewer] 🟡 MEDIUM
The old code used
model_copy(deep=True)for the parent'sQueryTagssnapshot; this uses a plain shallowmodel_copy()per worker.QueryTagshas nestedBaseModelfields (temporal,dagster) and mutable containers (dagster.tags,entity_math,query,query_settings) that a shallow copy shares by reference across workers and the caller.Two independent reviews checked this and agree it's not a live bug today — every real write path (
with_temporal,with_dagster,QueryTags.update) reassigns the whole field viasetattrrather than mutating a nested object in place, andtemporal/dagsterare only ever populated from Temporal/Dagster contexts that don't reach this helper. One review confirmed with a throwaway probe that if a worker did doget_query_tags().temporal.attempt = 99, the caller would see it leak.So this is a docstring-accuracy issue more than a bug: the docstring says a worker that "mutates its tags in place... only ever touches its own copy" — true for top-level fields, not true for nested ones. Either restore
deep=True(cheap relative to a ClickHouse query) or narrow the docstring's claim to top-level fields only, so a future worker that does reach into.temporal/.dagsterdoesn't get a false sense of safety from this comment.