Coverage for app/backend/src/tests/fixtures/query_log.py: 94%

152 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-10 04:35 +0000

1"""Records every SQL query the suite issues, grouped by test and by the RPC (or background job) that issued it. 

2 

3Off unless --query-log is passed, so ordinary local runs pay nothing. CI dumps one JSON file per pytest-split node; 

4app/scripts/query_log_report.py merges those, diffs them against develop's and renders the browsable report. 

5 

6Each query is stored twice: a fingerprint with the bound parameters replaced by "?" (the stable key used for 

7grouping and diffing) and one concrete rendering with the values inlined by psycopg, so it can be copy-pasted 

8straight into a psql prompt. 

9""" 

10 

11import gzip 

12import hashlib 

13import json 

14import os 

15import re 

16import sys 

17import threading 

18from dataclasses import dataclass, field 

19from pathlib import Path 

20from types import CodeType, FrameType 

21from typing import Any 

22 

23import psycopg 

24from sqlalchemy import Engine, event 

25 

26# Hard cap on what is stored per statement. Nothing this long is an access pattern worth diffing, and the cap is 

27# what bounds the artifact: uncapped, the timezone_areas load alone took a CI node's dump to 495 MB. 

28_MAX_SQL_CHARS = 4096 

29_TRUNCATION_MARKER = " /* truncated by the query log */" 

30 

31 

32@dataclass(slots=True) 

33class _Shape: 

34 id: str 

35 sql: str 

36 example: str 

37 params: str | None 

38 write: bool 

39 # The test that first produced this shape. Kept only to make the choice of `example` deterministic. 

40 first_seen_in: str 

41 

42 

43@dataclass(slots=True) 

44class _Span: 

45 kind: str 

46 name: str | None 

47 queries: list[str] = field(default_factory=list) 

48 # Parallel to queries: the call site each execution came from. Kept as a separate array so the diff, which reads 

49 # only `queries`, cannot be perturbed by line numbers shifting under an unrelated edit. 

50 sites: list[str] = field(default_factory=list) 

51 

52 

53_lock = threading.Lock() 

54_local = threading.local() 

55 

56_enabled = False 

57_current_test: str | None = None 

58_shapes: dict[str, _Shape] = {} 

59_tests: dict[str, list[_Span]] = {} 

60_sites: dict[str, str] = {} 

61_site_ids: dict[str, str] = {} 

62_frame_cache: dict[str, int] = {} 

63 

64 

65def _truncate(sql: str) -> str: 

66 return sql if len(sql) <= _MAX_SQL_CHARS else sql[:_MAX_SQL_CHARS] + _TRUNCATION_MARKER 

67 

68 

69def _fingerprint(statement: str) -> str: 

70 """The statement with everything that varies with the test data taken out, which is the key we group and diff on. 

71 

72 Anything left in here that moves between two identical runs turns the whole report into noise, so each step 

73 below is pinned by a test in test_query_log.py. 

74 """ 

75 # sqlcommenter-style comments. Nothing emits these in tests today (tracing is only set up in prod), but a future 

76 # change that turns the commenter on would otherwise invalidate every fingerprint at once. 

77 sql = re.sub(r"/\*.*?\*/", "", statement, flags=re.DOTALL) 

78 # Bind parameters, as psycopg's pyformat paramstyle renders them, plus positional %s for good measure. 

79 sql = re.sub(r"%\([^)]*\)s|%s", "?", sql) 

80 # An expanded IN (...) list, whose length tracks the test data. 

81 sql = re.sub(r"\?(?:\s*,\s*\?)+", "?", sql) 

82 # Repeated VALUES tuples from a multi-row insert, whose count tracks the batch size. 

83 sql = re.sub(r"\(\?\)(?:\s*,\s*\(\?\))+", "(?)", sql) 

84 # Literals inlined into the statement text rather than bound. Bulk resource loads do this: the real 

85 # timezone_areas.sql is a few hundred INSERTs each carrying megabytes of WKB hex, so without collapsing them 

86 # every row becomes its own multi-megabyte shape. 

87 sql = re.sub(r"'[^']{64,}'", "'...'", sql) 

88 return _truncate(re.sub(r"\s+", " ", sql).strip()) 

89 

90 

91def _shape_id(fingerprint: str) -> str: 

92 # Content-addressed, so the three pytest-split nodes agree on ids and merging is a plain dict union. A counter 

93 # would be assigned in per-node encounter order and collide across nodes. 

94 return hashlib.blake2b(fingerprint.encode(), digest_size=6).hexdigest() 

95 

96 

97def _render_example(conn: Any, statement: str, parameters: Any) -> tuple[str, str | None]: 

98 """Inline the bound values so the statement can be pasted into psql. 

99 

100 psycopg's ClientCursor.mogrify applies the same adaptation and escaping the driver would otherwise do 

101 server-side, which is far more faithful than re-implementing literal binding for bytea, arrays and PostGIS 

102 geometries. It is purely client-side, so it issues nothing on the connection. 

103 """ 

104 # executemany passes a sequence of parameter sets; one row is enough to have something runnable. 

105 if isinstance(parameters, (list, tuple)) and parameters and isinstance(parameters[0], (dict, list, tuple)): 

106 parameters = parameters[0] 

107 try: 

108 params = json.dumps(parameters, default=repr) if parameters else None 

109 except TypeError, ValueError: 

110 params = None 

111 if params is not None: 

112 params = _truncate(params) 

113 try: 

114 cursor = psycopg.ClientCursor(conn.connection.driver_connection) 

115 # Truncated past the cap, so the marker is a SQL comment: the result is visibly not runnable rather than 

116 # silently invalid. 

117 return _truncate(cursor.mogrify(statement, parameters)), params 

118 except Exception: 

119 # Not worth losing the whole entry over; the fingerprint plus the parameters is still pasteable by hand. 

120 return _truncate(statement), params 

121 

122 

123# Everything under the backend's src/ is ours; paths are reported relative to it. couchers/ is application code, 

124# anything else under src/ is test scaffolding. 

125_SRC_ROOT = "/src/" 

126_APP_ROOT = "/src/couchers/" 

127_SKIP, _APP, _TEST = 0, 1, 2 

128# Frames from these are plumbing between our code and the driver, so they never make a useful call site. The 

129# recorder's own path is spelled out: a bare "query_log.py" would also swallow test_query_log.py. 

130_CALLSITE_SKIP = ("/sqlalchemy/", "/psycopg", "/alembic/", "/fixtures/query_log.py") 

131 

132 

133def _frame_kind(code: CodeType) -> int: 

134 """_APP, _TEST or _SKIP. Cached by filename, which is all the answer depends on: this runs on every frame of 

135 every execution, and the string work is what would otherwise make stack walking too expensive to leave on. 

136 

137 Keyed by filename rather than by the code object, because code objects compare equal without regard to 

138 co_filename, so two same-bodied functions in different files would share an entry. 

139 """ 

140 filename = code.co_filename 

141 known = _frame_cache.get(filename) 

142 if known is None: 

143 if any(part in filename for part in _CALLSITE_SKIP) or _SRC_ROOT not in filename: 

144 known = _SKIP 

145 else: 

146 known = _APP if _APP_ROOT in filename else _TEST 

147 _frame_cache[filename] = known 

148 return known 

149 

150 

151# How many of our own frames to keep. The innermost is the line that issued the query; the next couple show the 

152# chain that got there, which is usually what tells you whether a repeat is a loop. 

153_CALLSITE_FRAMES = 3 

154 

155 

156def _callsite() -> str: 

157 """The innermost few application frames, innermost first, as "path:line in func". 

158 

159 Only couchers/ frames: the test and the fixture handler that got here are already implied by the test and span 

160 this is recorded under, and including them multiplies the number of distinct call sites for no added meaning. 

161 Test frames are used only when a query has no application frame at all, as fixture setup often does not. 

162 """ 

163 frames: list[str] = [] 

164 fallback = "" 

165 frame: FrameType | None = sys._getframe(1) 

166 while frame is not None and len(frames) < _CALLSITE_FRAMES: 

167 code = frame.f_code 

168 kind = _frame_kind(code) 

169 if kind != _SKIP: 

170 path = code.co_filename.split(_SRC_ROOT, 1)[-1] 

171 rendered_frame = f"{path}:{frame.f_lineno} in {code.co_name}" 

172 if kind == _APP: 

173 frames.append(rendered_frame) 

174 elif not fallback: 

175 fallback = rendered_frame 

176 frame = frame.f_back 

177 rendered = " <- ".join(frames) or fallback 

178 site_id = _site_ids.get(rendered) 

179 if site_id is None: 

180 site_id = _shape_id(rendered) 

181 _site_ids[rendered] = site_id 

182 _sites[site_id] = rendered 

183 return site_id 

184 

185 

186def _current_span() -> _Span | None: 

187 return getattr(_local, "span", None) 

188 

189 

190# test_db rebuilds the schema from migrations to diff it against the models. That is schema plumbing rather than an 

191# access pattern, and it is already covered by the schema-diff artifact. Note this is not what keeps the real 

192# timezone_areas.sql out of the recording: test_migrations is skipped in test:backend anyway, because the backend 

193# image has no pg_dump. What bounds the artifact is _MAX_SQL_CHARS. 

194_EXCLUDED_MODULES = ("src/tests/test_db.py",) 

195 

196 

197def _after_cursor_execute(conn, cursor, statement, parameters, context, executemany): 

198 test = _current_test 

199 if test is None or test.startswith(_EXCLUDED_MODULES): 

200 return 

201 fingerprint = _fingerprint(statement) 

202 with _lock: 

203 shape = _shapes.get(fingerprint) 

204 if shape is None or test < shape.first_seen_in: 

205 example, params = _render_example(conn, statement, parameters) 

206 # The context knows for ORM-issued statements; fall back to reading the statement for the rest. 

207 is_write = bool( 

208 (context is not None and (context.isinsert or context.isupdate or context.isdelete)) 

209 or re.match(r"^\s*(INSERT|UPDATE|DELETE)\b", statement, re.IGNORECASE) 

210 ) 

211 _shapes[fingerprint] = _Shape( 

212 id=_shape_id(fingerprint), 

213 sql=fingerprint, 

214 example=example, 

215 params=params, 

216 write=is_write, 

217 first_seen_in=test, 

218 ) 

219 shape = _shapes[fingerprint] 

220 

221 span = _current_span() 

222 if span is None: 

223 # A query outside any RPC or job: fixture setup, or the test body using session_scope() directly. 

224 spans = _tests.setdefault(test, []) 

225 if spans and spans[-1].kind == "body": 

226 span = spans[-1] 

227 else: 

228 span = _Span(kind="body", name=None) 

229 spans.append(span) 

230 span.queries.append(shape.id) 

231 span.sites.append(_callsite()) 

232 

233 

234class _SpanScope: 

235 """Marks the queries issued inside it as belonging to one RPC call or background job run. 

236 

237 The span is registered against the current test on entry, so the recorded order matches call order. It is held 

238 in a thread-local because the real-server sessions run handlers on a gRPC executor thread while the test body 

239 runs on the main thread; FakeChannel runs them inline, and the same thread-local covers that too. 

240 """ 

241 

242 def __init__(self, kind: str, name: str | None): 

243 self._span = _Span(kind=kind, name=name) 

244 self._previous: _Span | None = None 

245 

246 def __enter__(self) -> _SpanScope: 

247 if _current_test is not None: 

248 with _lock: 

249 _tests.setdefault(_current_test, []).append(self._span) 

250 self._previous = _current_span() 

251 _local.span = self._span 

252 return self 

253 

254 def __exit__(self, *exc: object) -> None: 

255 _local.span = self._previous 

256 

257 

258def span(kind: str, name: str | None) -> Any: 

259 """Open a recording span. A no-op unless --query-log is active, so callers need no guard of their own.""" 

260 if not _enabled: 

261 return _NULL_SPAN 

262 return _SpanScope(kind, name) 

263 

264 

265class _NullSpan: 

266 def __enter__(self) -> _NullSpan: 

267 return self 

268 

269 def __exit__(self, *exc: object) -> None: 

270 pass 

271 

272 

273_NULL_SPAN = _NullSpan() 

274 

275 

276def enable(engine: Engine) -> None: 

277 global _enabled 

278 _enabled = True 

279 event.listen(engine, "after_cursor_execute", _after_cursor_execute) 

280 

281 

282def set_current_test(test_id: str | None) -> None: 

283 global _current_test 

284 _current_test = test_id 

285 _local.span = None 

286 

287 

288def dump(directory: Path) -> Path: 

289 """Write this node's recording. The node suffix keeps the parallel CI jobs from overwriting each other. 

290 

291 Gzipped: it is mostly repeated SQL and compresses about fifteen-fold, and this file is carried between CI jobs as 

292 an artifact. Read it with `gunzip -c`, or let query_log_report.py merge it. 

293 """ 

294 node = os.environ.get("CI_NODE_INDEX", "local") 

295 directory.mkdir(parents=True, exist_ok=True) 

296 path = directory / f"data.{node}.json.gz" 

297 with _lock: 

298 data = { 

299 "shapes": { 

300 shape.id: { 

301 "sql": shape.sql, 

302 "example": shape.example, 

303 "params": shape.params, 

304 "write": shape.write, 

305 # The merge step uses this to pick the same `example` the single-node run would have picked. 

306 "first_seen_in": shape.first_seen_in, 

307 } 

308 for shape in _shapes.values() 

309 }, 

310 "sites": dict(sorted(_sites.items())), 

311 "tests": { 

312 test: [{"kind": s.kind, "name": s.name, "queries": s.queries, "sites": s.sites} for s in spans] 

313 for test, spans in sorted(_tests.items()) 

314 }, 

315 } 

316 # mtime=0 keeps the bytes reproducible, so two identical runs produce byte-identical dumps. 

317 path.write_bytes(gzip.compress(json.dumps(data, separators=(",", ":"), sort_keys=True).encode(), mtime=0)) 

318 return path