|
| 1 | +"""Guards for the two ways the async calls reads can silently diverge from sync. |
| 2 | +
|
| 3 | +Both are failure modes that produce wrong data or wrong threading rather than an |
| 4 | +exception, so neither would show up in a smoke test. |
| 5 | +""" |
| 6 | + |
| 7 | +import asyncio |
| 8 | +import threading |
| 9 | +from unittest.mock import MagicMock, patch |
| 10 | + |
| 11 | +import pytest |
| 12 | + |
| 13 | +from weave.trace_server import trace_server_interface as tsi |
| 14 | +from weave.trace_server.async_clickhouse_trace_server import ( |
| 15 | + AsyncClickHouseTraceServer, |
| 16 | +) |
| 17 | +from weave.trace_server.calls_query_builder.calls_query_builder import ( |
| 18 | + CallsMergedField, |
| 19 | + OrderField, |
| 20 | +) |
| 21 | +from weave.trace_server.token_costs import get_cost_result_columns |
| 22 | + |
| 23 | +PROJECT = "UHJvamVjdEludGVybmFsSWQ6MQ==" |
| 24 | + |
| 25 | + |
| 26 | +def test_acalls_query_stats_never_touches_ch_client_on_the_event_loop(): |
| 27 | + """`ch_client` is thread-local and minting one blocks. |
| 28 | +
|
| 29 | + Passed as an argument to `to_thread` it would be evaluated on the loop: |
| 30 | + blocking it on first use, and then handing one thread's client to every |
| 31 | + other thread running this concurrently. |
| 32 | + """ |
| 33 | + server = AsyncClickHouseTraceServer(host="test_host") |
| 34 | + loop_thread = threading.get_ident() |
| 35 | + touched_on_loop: list[bool] = [] |
| 36 | + |
| 37 | + class Guard: |
| 38 | + def __get__(self, obj, owner=None): |
| 39 | + touched_on_loop.append(threading.get_ident() == loop_thread) |
| 40 | + return MagicMock() |
| 41 | + |
| 42 | + async def run(): |
| 43 | + with ( |
| 44 | + patch.object(type(server), "ch_client", Guard()), |
| 45 | + patch.object(type(server), "table_routing_resolver", MagicMock()), |
| 46 | + patch.object(server, "_aquery", return_value=MagicMock(result_rows=[[0]])), |
| 47 | + patch( |
| 48 | + "weave.trace_server.async_clickhouse_trace_server.build_calls_stats_query", |
| 49 | + return_value=("SELECT 1", ["count"], None), |
| 50 | + ), |
| 51 | + patch( |
| 52 | + "weave.trace_server.async_clickhouse_trace_server.calls_stats_res", |
| 53 | + return_value=tsi.CallsQueryStatsRes(count=0), |
| 54 | + ), |
| 55 | + ): |
| 56 | + await server.acalls_query_stats(tsi.CallsQueryStatsReq(project_id=PROJECT)) |
| 57 | + |
| 58 | + asyncio.run(run()) |
| 59 | + assert touched_on_loop, "ch_client was never resolved; the test proves nothing" |
| 60 | + assert not any(touched_on_loop), ( |
| 61 | + "ch_client was resolved on the event loop thread; it must be resolved " |
| 62 | + "inside the executor callback" |
| 63 | + ) |
| 64 | + |
| 65 | + |
| 66 | +class _Captured(Exception): |
| 67 | + """Carries the zip keys out before any downstream validation runs.""" |
| 68 | + |
| 69 | + def __init__(self, columns): |
| 70 | + self.columns = columns |
| 71 | + |
| 72 | + |
| 73 | +@pytest.mark.parametrize("include_costs", [True, False]) |
| 74 | +def test_acalls_query_maps_columns_the_way_sync_does(include_costs: bool): |
| 75 | + """A cost query's SELECT carries sort-only columns ahead of `summary_dump`. |
| 76 | +
|
| 77 | + Zipping row values against `select_fields` alone shifts every value after |
| 78 | + that point onto the wrong key, which is silent rather than an error. |
| 79 | + """ |
| 80 | + server = AsyncClickHouseTraceServer(host="test_host") |
| 81 | + select = ["id", "project_id", "summary_dump"] |
| 82 | + order = [OrderField(field=CallsMergedField(field="started_at"), direction="ASC")] |
| 83 | + cq = MagicMock( |
| 84 | + select_fields=[MagicMock(field=f) for f in select], order_fields=order |
| 85 | + ) |
| 86 | + cq.as_sql.return_value = "SELECT 1" |
| 87 | + |
| 88 | + expected = get_cost_result_columns(select, order) if include_costs else select |
| 89 | + |
| 90 | + def capture(d): |
| 91 | + raise _Captured(list(d)) |
| 92 | + |
| 93 | + async def run(): |
| 94 | + with ( |
| 95 | + patch.object(server, "_build_calls_query", return_value=(cq, None)), |
| 96 | + patch.object( |
| 97 | + server, |
| 98 | + "_aquery", |
| 99 | + return_value=MagicMock(result_rows=[list(range(len(expected)))]), |
| 100 | + ), |
| 101 | + patch( |
| 102 | + "weave.trace_server.async_clickhouse_trace_server.ch_call_dict_to_call_schema_dict", |
| 103 | + side_effect=capture, |
| 104 | + ), |
| 105 | + ): |
| 106 | + await server.acalls_query( |
| 107 | + tsi.CallsQueryReq(project_id=PROJECT, include_costs=include_costs) |
| 108 | + ) |
| 109 | + |
| 110 | + with pytest.raises(_Captured) as exc: |
| 111 | + asyncio.run(run()) |
| 112 | + assert exc.value.columns == expected |
0 commit comments