Skip to content

Commit f8bfa1c

Browse files
fix(schema-drift): atomic drift merge, unlimited sample mode, baseline root check (#108)
1 parent aa6b31f commit f8bfa1c

3 files changed

Lines changed: 68 additions & 14 deletions

File tree

tests/test_schema_drift.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6+
import threading
67
from pathlib import Path
78

89
import pytest
@@ -168,3 +169,46 @@ def test_schema_drift_disabled_skips_fingerprint(self, monkeypatch):
168169
monkeypatch.setenv("CLAUDE_CODE_CHAT_BROWSER_SCHEMA_DRIFT", "0")
169170
parse_session(str(UNKNOWN_FIELD_FIXTURE))
170171
assert get_schema_report()["has_drift"] is False
172+
173+
def test_sample_limit_one_skips_later_records(self, monkeypatch):
174+
monkeypatch.setenv("CLAUDE_CODE_CHAT_BROWSER_SCHEMA_DRIFT_SAMPLE", "1")
175+
parse_session(str(UNKNOWN_FIELD_FIXTURE))
176+
report = get_schema_report()
177+
assert "tool" not in report["new_fields"]
178+
179+
def test_sample_limit_zero_fingerprints_all_records(self, monkeypatch):
180+
monkeypatch.setenv("CLAUDE_CODE_CHAT_BROWSER_SCHEMA_DRIFT_SAMPLE", "0")
181+
parse_session(str(UNKNOWN_FIELD_FIXTURE))
182+
report = get_schema_report()
183+
assert "tool" in report["new_fields"]
184+
185+
def test_non_object_baseline_root_is_non_fatal(self, tmp_path, monkeypatch):
186+
bad_baseline = tmp_path / "schema_baseline.json"
187+
bad_baseline.write_text('["not", "an", "object"]', encoding="utf-8")
188+
monkeypatch.setattr("utils.schema_drift.BASELINE_PATH", bad_baseline)
189+
clear_baseline_cache()
190+
assert record_parse_drift({"type"}) is None
191+
192+
def test_concurrent_record_parse_drift_merges_all_fields(self):
193+
start = threading.Barrier(10)
194+
errors: list[BaseException] = []
195+
196+
def worker(field: str) -> None:
197+
try:
198+
start.wait(timeout=5)
199+
record_parse_drift({"type", field})
200+
except BaseException as exc:
201+
errors.append(exc)
202+
203+
threads = [
204+
threading.Thread(target=worker, args=(f"concurrent_field_{i}",)) for i in range(10)
205+
]
206+
for thread in threads:
207+
thread.start()
208+
for thread in threads:
209+
thread.join()
210+
211+
assert errors == []
212+
report = get_schema_report()
213+
for i in range(10):
214+
assert f"concurrent_field_{i}" in report["new_fields"]

utils/jsonl_parser.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,11 @@ def parse_session(filepath: str) -> SessionDict:
175175
messages: list[MessageDict] = []
176176
metadata = _new_session_metadata_builder(session_id)
177177
observed_field_paths: set[str] = set()
178-
schema_samples_remaining = schema_drift_sample_limit() if is_schema_drift_enabled() else 0
178+
if is_schema_drift_enabled():
179+
sample_limit = schema_drift_sample_limit()
180+
schema_samples_remaining: int | None = None if sample_limit == 0 else sample_limit
181+
else:
182+
schema_samples_remaining = -1
179183

180184
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
181185
for line in f:
@@ -190,9 +194,12 @@ def parse_session(filepath: str) -> SessionDict:
190194
if not isinstance(entry, dict):
191195
continue
192196

193-
if schema_samples_remaining > 0:
197+
if schema_samples_remaining != -1 and (
198+
schema_samples_remaining is None or schema_samples_remaining > 0
199+
):
194200
observed_field_paths |= collect_field_paths(entry)
195-
schema_samples_remaining -= 1
201+
if schema_samples_remaining is not None:
202+
schema_samples_remaining -= 1
196203

197204
entry_type = entry.get("type")
198205
ts = _entry_timestamp(entry)

utils/schema_drift.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def is_schema_drift_enabled() -> bool:
4141

4242

4343
def schema_drift_sample_limit() -> int:
44-
"""Max JSONL records per session to fingerprint (default 3). Set 0 to disable sampling cap."""
44+
"""Max JSONL records per session to fingerprint (default 3). 0 means no cap (all records)."""
4545
raw = os.environ.get("CLAUDE_CODE_CHAT_BROWSER_SCHEMA_DRIFT_SAMPLE", "3").strip()
4646
try:
4747
return max(0, int(raw))
@@ -75,6 +75,8 @@ def _load_baseline() -> tuple[frozenset[str], frozenset[str]]:
7575
return _baseline_cache
7676
try:
7777
raw = json.loads(BASELINE_PATH.read_text(encoding="utf-8"))
78+
if not isinstance(raw, dict):
79+
raise ValueError("schema_baseline.json: root must be an object")
7880
fields = raw.get("fields", {})
7981
if not isinstance(fields, dict):
8082
raise ValueError("schema_baseline.json: 'fields' must be an object")
@@ -122,30 +124,31 @@ def record_parse_drift(observed_paths: set[str]) -> SchemaDriftReport | None:
122124
except (OSError, json.JSONDecodeError, ValueError, TypeError):
123125
return None
124126

127+
genuinely_new: list[str] = []
128+
missing_fields: list[str] = []
125129
with _lock:
126130
global _last_report
127131
prior_new = set(_last_report["new_fields"])
128132
genuinely_new = sorted(set(report["new_fields"]) - prior_new)
129133
merged_new = sorted(prior_new | set(report["new_fields"]))
134+
missing_fields = list(report["missing_fields"])
135+
_last_report = {
136+
"known_fields": report["known_fields"],
137+
"new_fields": merged_new,
138+
"missing_fields": missing_fields,
139+
"has_drift": bool(merged_new or missing_fields),
140+
}
130141

131142
if genuinely_new:
132143
_log.warning(
133144
"schema drift: new JSONL field paths not in baseline: %s",
134145
genuinely_new,
135146
)
136-
if report["missing_fields"]:
147+
if missing_fields:
137148
_log.warning(
138149
"schema drift: missing required JSONL field paths in sampled records: %s",
139-
report["missing_fields"],
150+
missing_fields,
140151
)
141-
142-
with _lock:
143-
_last_report = {
144-
"known_fields": report["known_fields"],
145-
"new_fields": merged_new,
146-
"missing_fields": list(report["missing_fields"]),
147-
"has_drift": bool(merged_new or report["missing_fields"]),
148-
}
149152
return report
150153

151154

0 commit comments

Comments
 (0)