Skip to content

Commit 6386841

Browse files
committed
fix: parse deeply nested auto-explain JSON
1 parent a077026 commit 6386841

2 files changed

Lines changed: 189 additions & 1 deletion

File tree

‎src/pg_diag/logscan/auto_explain.py‎

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import json
66
import math
77
import re
8+
from dataclasses import dataclass
9+
from json.decoder import scanstring
810
from typing import Any
911
from xml.etree import ElementTree
1012

@@ -23,6 +25,17 @@
2325
_XML_LIST_CHILDREN = frozenset({"Item", "Plan", "Setting", "Trigger", "Worker"})
2426
_XML_INTEGER_RE = re.compile(r"^-?\d+$")
2527
_XML_DECIMAL_RE = re.compile(r"^-?(?:\d+\.\d*|\d*\.\d+)(?:[eE][+-]?\d+)?$")
28+
_JSON_NUMBER_RE = re.compile(r"-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?")
29+
_JSON_PLAN_ROLES = frozenset({"root_plan", "plan_node"})
30+
31+
32+
@dataclass
33+
class _JsonFrame:
34+
kind: str
35+
role: str
36+
state: str
37+
key: str | None = None
38+
index: int = 0
2639

2740

2841
def parse_auto_explain(message: str, *, complete: bool) -> AutoExplainPlan | None:
@@ -61,7 +74,13 @@ def _parse_plan(plan_text: str) -> tuple[str, str | None, int, bool, str | None]
6174
def _parse_json(plan_text: str) -> tuple[str, str | None, int, bool, str | None]:
6275
try:
6376
payload: Any = json.loads(plan_text)
64-
except (TypeError, ValueError, RecursionError):
77+
except RecursionError:
78+
metadata = _parse_deep_json_metadata(plan_text)
79+
if metadata is None:
80+
return "json", None, 0, False, None
81+
root, node_count, query_text = metadata
82+
return "json", root, node_count, bool(root and node_count), query_text
83+
except (TypeError, ValueError):
6584
return "json", None, 0, False, None
6685
if isinstance(payload, list) and payload:
6786
payload = payload[0]
@@ -80,6 +99,148 @@ def _parse_json(plan_text: str) -> tuple[str, str | None, int, bool, str | None]
8099
)
81100

82101

102+
def _parse_deep_json_metadata(plan_text: str) -> tuple[str | None, int, str | None] | None:
103+
"""Extract bounded plan metadata without recursive JSON object construction."""
104+
frames: list[_JsonFrame] = []
105+
root_started = False
106+
root_complete = False
107+
root_node_type: str | None = None
108+
node_count = 0
109+
query_text: str | None = None
110+
111+
def start_value(kind: str, value: str | None, role: str) -> None:
112+
nonlocal node_count, query_text, root_node_type
113+
if kind == "{":
114+
if role in _JSON_PLAN_ROLES:
115+
node_count += 1
116+
frames.append(_JsonFrame("object", role, "key_or_end"))
117+
return
118+
if kind == "[":
119+
frames.append(_JsonFrame("array", role, "value_or_end"))
120+
return
121+
if kind not in {"string", "scalar"}:
122+
raise ValueError("expected JSON value")
123+
if kind == "string" and role == "query_text":
124+
query_text = value
125+
elif kind == "string" and role == "root_node_type":
126+
root_node_type = value
127+
128+
def object_value_role(frame: _JsonFrame) -> str:
129+
if frame.role == "payload":
130+
if frame.key == "Plan":
131+
return "root_plan"
132+
if frame.key == "Query Text":
133+
return "query_text"
134+
if frame.role in _JSON_PLAN_ROLES:
135+
if frame.key == "Plans":
136+
return "plans_array"
137+
if frame.role == "root_plan" and frame.key == "Node Type":
138+
return "root_node_type"
139+
return "other"
140+
141+
def array_value_role(frame: _JsonFrame) -> str:
142+
if frame.role == "root_array" and frame.index == 0:
143+
return "payload"
144+
if frame.role == "plans_array":
145+
return "plan_node"
146+
return "other"
147+
148+
try:
149+
for kind, value in _iter_json_tokens(plan_text):
150+
if not frames:
151+
if root_started:
152+
raise ValueError("trailing JSON content")
153+
root_started = True
154+
role = "payload" if kind == "{" else "root_array" if kind == "[" else "other"
155+
start_value(kind, value, role)
156+
if not frames:
157+
root_complete = True
158+
continue
159+
160+
frame = frames[-1]
161+
if frame.kind == "object":
162+
if frame.state in {"key_or_end", "key"}:
163+
if kind == "}" and frame.state == "key_or_end":
164+
frames.pop()
165+
root_complete = not frames
166+
elif kind == "string":
167+
frame.key = value
168+
frame.state = "colon"
169+
else:
170+
raise ValueError("expected JSON object key")
171+
elif frame.state == "colon":
172+
if kind != ":":
173+
raise ValueError("expected colon after JSON object key")
174+
frame.state = "value"
175+
elif frame.state == "value":
176+
role = object_value_role(frame)
177+
frame.key = None
178+
frame.state = "comma_or_end"
179+
start_value(kind, value, role)
180+
elif frame.state == "comma_or_end":
181+
if kind == "}":
182+
frames.pop()
183+
root_complete = not frames
184+
elif kind == ",":
185+
frame.state = "key"
186+
else:
187+
raise ValueError("expected comma or JSON object end")
188+
continue
189+
190+
if frame.state in {"value_or_end", "value"}:
191+
if kind == "]" and frame.state == "value_or_end":
192+
frames.pop()
193+
root_complete = not frames
194+
continue
195+
role = array_value_role(frame)
196+
frame.index += 1
197+
frame.state = "comma_or_end"
198+
start_value(kind, value, role)
199+
elif frame.state == "comma_or_end":
200+
if kind == "]":
201+
frames.pop()
202+
root_complete = not frames
203+
elif kind == ",":
204+
frame.state = "value"
205+
else:
206+
raise ValueError("expected comma or JSON array end")
207+
if frames or not root_started or not root_complete:
208+
raise ValueError("incomplete JSON value")
209+
except (TypeError, ValueError):
210+
return None
211+
return root_node_type, node_count, query_text
212+
213+
214+
def _iter_json_tokens(plan_text: str):
215+
index = 0
216+
while index < len(plan_text):
217+
character = plan_text[index]
218+
if character.isspace():
219+
index += 1
220+
continue
221+
if character in "{}[]:,":
222+
yield character, None
223+
index += 1
224+
continue
225+
if character == '"':
226+
value, index = scanstring(plan_text, index + 1, True)
227+
yield "string", value
228+
continue
229+
number_match = _JSON_NUMBER_RE.match(plan_text, index)
230+
if number_match is not None:
231+
yield "scalar", None
232+
index = number_match.end()
233+
continue
234+
literal = next(
235+
(candidate for candidate in ("true", "false", "null") if plan_text.startswith(candidate, index)),
236+
None,
237+
)
238+
if literal is None:
239+
raise ValueError("invalid JSON token")
240+
yield "scalar", None
241+
index += len(literal)
242+
243+
83244
def _count_json_nodes(plan: dict[str, Any]) -> int:
84245
count = 0
85246
pending = [plan]

‎tests/unit/test_logscan_core.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,33 @@ def test_parse_auto_explain_deep_plan_does_not_abort_log_phase(plan_format: str)
299299
assert parsed.viewer_plan is None
300300

301301

302+
def test_parse_auto_explain_json_recursion_fallback_preserves_metadata(monkeypatch) -> None:
303+
body = json.dumps(
304+
{
305+
"Query Text": "select * from accounts where password = 'secret'",
306+
"Plan": {
307+
"Node Type": "Subquery Scan",
308+
"Plans": [{"Node Type": "Result"}],
309+
},
310+
}
311+
)
312+
313+
def raise_recursion_error(_value: str):
314+
raise RecursionError
315+
316+
monkeypatch.setattr(auto_explain.json, "loads", raise_recursion_error)
317+
parsed = auto_explain.parse_auto_explain(f"duration: 1 ms plan:\n{body}", complete=True)
318+
319+
assert parsed is not None
320+
assert parsed.parsed
321+
assert parsed.root_node_type == "Subquery Scan"
322+
assert parsed.node_count == 2
323+
assert parsed.query_sample is not None
324+
assert "secret" not in parsed.query_sample
325+
assert "[REDACTED]" in parsed.query_sample
326+
assert parsed.viewer_plan is None
327+
328+
302329
# --- physical RLE ---
303330

304331

0 commit comments

Comments
 (0)