Skip to content

Commit 4ed79d6

Browse files
Brian KrafftCopilot
andcommitted
fix(3.4): address /8eyes R1 — duplicate rows, atomic bulk, layering, json safety
- Finding 1: Add DISTINCT to load_analyses/load_recent_analyses_for_skill to prevent duplicate rows from JOIN - Finding 2: Validate ALL analyses before inserting any in shared-conn mode bulk_upsert for atomicity - Finding 3: Make _insert_analysis public (insert_analysis) - fix store.py layering violation - Finding 4: Add _safe_json_loads helper for json.loads with error handling - prevent crashes on corrupt DB data - Finding 5: Fix docstring async/sync mismatch - remove await from examples Tests: All 18 analysis store tests pass + 3 new tests for duplicate prevention, atomicity, public API Full suite: 1231 passed (baseline maintained) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ee8ba91 commit 4ed79d6

3 files changed

Lines changed: 145 additions & 11 deletions

File tree

openspace/skill_engine/analysis_store.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ class AnalysisStore:
108108
Usage (standalone)::
109109
110110
store = AnalysisStore(db_path=Path("analyses.db"))
111-
await store.record_execution_analysis(analysis)
111+
store.record_execution_analysis(analysis)
112112
recent = store.load_analyses(skill_id="some_skill")
113113
store.close()
114114
@@ -221,7 +221,7 @@ def record_execution_analysis_sync(self, analysis: ExecutionAnalysis) -> None:
221221
with self._mu:
222222
self._conn.execute("BEGIN")
223223
try:
224-
analysis_id = self._insert_analysis(analysis)
224+
analysis_id = self.insert_analysis(analysis)
225225
self._conn.commit()
226226
except Exception:
227227
self._conn.rollback()
@@ -243,8 +243,9 @@ def load_analyses(
243243
"""
244244
with self._reader() as conn:
245245
if skill_id is not None:
246+
# Use DISTINCT to avoid duplicate analyses when multiple judgments match
246247
rows = conn.execute(
247-
"SELECT ea.* FROM execution_analyses ea "
248+
"SELECT DISTINCT ea.* FROM execution_analyses ea "
248249
"JOIN skill_judgments sj ON ea.id = sj.analysis_id "
249250
"WHERE sj.skill_id = ? "
250251
"ORDER BY ea.timestamp DESC LIMIT ?",
@@ -300,8 +301,9 @@ def load_recent_analyses_for_skill(self, skill_id: str, limit: int = 5) -> List[
300301
so filtering uses exact match.
301302
"""
302303
with self._reader() as conn:
304+
# Use DISTINCT to avoid duplicate analyses when multiple judgments match
303305
analysis_rows = conn.execute(
304-
"SELECT ea.* FROM execution_analyses ea "
306+
"SELECT DISTINCT ea.* FROM execution_analyses ea "
305307
"JOIN skill_judgments sj ON ea.id = sj.analysis_id "
306308
"WHERE sj.skill_id = ? "
307309
"ORDER BY ea.timestamp DESC LIMIT ?",
@@ -361,21 +363,25 @@ def bulk_upsert_analyses(self, analyses: List[ExecutionAnalysis]) -> None:
361363
(a.task_id,),
362364
).fetchone()
363365
if existing is None:
364-
self._insert_analysis(a)
366+
self.insert_analysis(a)
365367
self._conn.commit()
366368
except Exception:
367369
self._conn.rollback()
368370
raise
369371
else:
370372
# Shared connection mode - assume caller has transaction and lock
371-
# No mutex acquisition needed, just do the work
373+
# Validate ALL analyses first before inserting any to ensure atomicity
374+
for a in analyses:
375+
a.validate()
376+
377+
# Then insert all (caller's transaction guarantees atomicity)
372378
for a in analyses:
373379
existing = self._conn.execute(
374380
"SELECT id FROM execution_analyses WHERE task_id=?",
375381
(a.task_id,),
376382
).fetchone()
377383
if existing is None:
378-
self._insert_analysis(a)
384+
self.insert_analysis(a)
379385

380386
@_db_retry()
381387
def clear_all_analyses(self) -> None:
@@ -435,7 +441,7 @@ def get_task_skill_summary(self, task_id: str) -> Dict[str, Any]:
435441
"timestamp": row["timestamp"],
436442
"task_completed": bool(row["task_completed"]),
437443
"execution_note": row["execution_note"],
438-
"tool_issues": json.loads(row["tool_issues"]),
444+
"tool_issues": AnalysisStore._safe_json_loads(row["tool_issues"], []),
439445
"candidate_for_evolution": bool(row["candidate_for_evolution"]),
440446
"evolution_suggestions": evo_suggestions,
441447
"analyzed_by": row["analyzed_by"],
@@ -464,9 +470,17 @@ def get_analysis_stats(self) -> Dict[str, int]:
464470
"evolution_candidates": n_candidates,
465471
}
466472

473+
@staticmethod
474+
def _safe_json_loads(json_str: str, default: Any) -> Any:
475+
"""Safely parse JSON string, returning default on error."""
476+
try:
477+
return json.loads(json_str)
478+
except json.JSONDecodeError:
479+
return default
480+
467481
# ── Private Helpers ────────────────────────────────────────────────
468482

469-
def _insert_analysis(self, a: ExecutionAnalysis) -> int:
483+
def insert_analysis(self, a: ExecutionAnalysis) -> int:
470484
"""Insert an execution_analyses row + its skill_judgments.
471485
472486
Called within a transaction holding ``self._mu``.
@@ -537,7 +551,7 @@ def _to_analysis(conn: sqlite3.Connection, row: sqlite3.Row) -> ExecutionAnalysi
537551
timestamp=datetime.fromisoformat(row["timestamp"]),
538552
task_completed=bool(row["task_completed"]),
539553
execution_note=row["execution_note"],
540-
tool_issues=json.loads(row["tool_issues"]),
554+
tool_issues=AnalysisStore._safe_json_loads(row["tool_issues"], []),
541555
skill_judgments=[
542556
SkillJudgment(
543557
skill_id=jr["skill_id"],

openspace/skill_engine/store.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,7 @@ def _record_analysis_sync(self, analysis: ExecutionAnalysis) -> None:
567567
self._conn.execute("BEGIN")
568568
try:
569569
# Delegate analysis storage to AnalysisStore (Epic 3.4)
570-
self._analyses._insert_analysis(analysis)
570+
self._analyses.insert_analysis(analysis)
571571

572572
# Update skill counters in skill_records (remains in SkillStore)
573573
now_iso = datetime.now().isoformat()

tests/test_analysis_store.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,4 +406,124 @@ def test_analysis_store_integration_with_skill_store(temp_db_path):
406406
summary = store.get_task_skill_summary("integration_test")
407407
assert summary["task_id"] == "integration_test"
408408

409+
store.close()
410+
411+
412+
def test_duplicate_judgments_no_duplicate_analysis_rows(temp_db_path):
413+
"""Test that multiple judgments for same analysis don't create duplicate analysis rows."""
414+
store = AnalysisStore(db_path=temp_db_path)
415+
416+
# Create analysis with multiple judgments for same skill
417+
analysis = ExecutionAnalysis(
418+
task_id="duplicate_judgment_test",
419+
timestamp=datetime.now(),
420+
skill_judgments=[
421+
SkillJudgment(skill_id="shared_skill", skill_applied=True, note="First judgment"),
422+
SkillJudgment(skill_id="shared_skill", skill_applied=False, note="Second judgment"),
423+
SkillJudgment(skill_id="other_skill", skill_applied=True, note="Other judgment"),
424+
]
425+
)
426+
427+
store.record_execution_analysis_sync(analysis)
428+
429+
# Load analyses for the shared skill - should only get one analysis back
430+
analyses = store.load_analyses(skill_id="shared_skill", limit=10)
431+
assert len(analyses) == 1, "Should not get duplicate analysis rows due to multiple judgments"
432+
assert analyses[0].task_id == "duplicate_judgment_test"
433+
434+
# Load recent analyses - should also not have duplicates
435+
recent_analyses = store.load_recent_analyses_for_skill("shared_skill", limit=10)
436+
assert len(recent_analyses) == 1, "Should not get duplicate analysis rows in recent analyses"
437+
438+
store.close()
439+
440+
441+
def test_bulk_upsert_atomicity_with_validation_error(temp_db_path):
442+
"""Test that bulk_upsert with mixed valid/invalid analyses is all-or-nothing."""
443+
import sqlite3
444+
import threading
445+
446+
# Test in shared connection mode where atomicity issue exists
447+
conn = sqlite3.connect(str(temp_db_path))
448+
conn.row_factory = sqlite3.Row
449+
lock = threading.Lock()
450+
451+
# Initialize DDL
452+
from openspace.skill_engine.analysis_store import _DDL
453+
conn.executescript(_DDL)
454+
455+
store = AnalysisStore(conn=conn, lock=lock)
456+
457+
# Create mix of valid and invalid analyses
458+
valid_analysis1 = ExecutionAnalysis(
459+
task_id="valid_1",
460+
timestamp=datetime.now(),
461+
execution_note="Valid analysis 1"
462+
)
463+
464+
valid_analysis2 = ExecutionAnalysis(
465+
task_id="valid_2",
466+
timestamp=datetime.now(),
467+
execution_note="Valid analysis 2"
468+
)
469+
470+
# Create invalid analysis (missing required task_id)
471+
invalid_analysis = ExecutionAnalysis(
472+
task_id="", # Invalid empty task_id
473+
timestamp=datetime.now(),
474+
execution_note="Invalid analysis"
475+
)
476+
477+
analyses = [valid_analysis1, invalid_analysis, valid_analysis2]
478+
479+
# Bulk upsert should fail due to invalid analysis
480+
with pytest.raises(Exception): # Should raise validation error
481+
with lock:
482+
conn.execute("BEGIN")
483+
try:
484+
store.bulk_upsert_analyses(analyses)
485+
conn.commit()
486+
except Exception:
487+
conn.rollback()
488+
raise
489+
490+
# Verify NO analyses were inserted (atomicity)
491+
all_analyses = store.load_all_analyses()
492+
assert len(all_analyses) == 0, "No analyses should be inserted when bulk operation fails"
493+
494+
conn.close()
495+
496+
497+
def test_store_calls_public_insert_analysis_method(tmp_path):
498+
"""Test that store.py calls the public insert_analysis method (not private)."""
499+
# This test verifies the layering fix - store.py should call public methods
500+
temp_db_path = tmp_path / "test_public_method.db"
501+
502+
from openspace.skill_engine.store import SkillStore
503+
504+
store = SkillStore(db_path=temp_db_path)
505+
506+
# Verify the AnalysisStore has public insert_analysis method
507+
assert hasattr(store._analyses, 'insert_analysis'), "insert_analysis should be public"
508+
509+
# Verify it's callable (not private)
510+
assert callable(store._analyses.insert_analysis), "insert_analysis should be callable"
511+
512+
# This should work without accessing private methods
513+
analysis = ExecutionAnalysis(
514+
task_id="public_method_test",
515+
timestamp=datetime.now(),
516+
skill_judgments=[
517+
SkillJudgment(skill_id="test_skill", skill_applied=True)
518+
]
519+
)
520+
521+
import asyncio
522+
asyncio.run(store.record_analysis(analysis))
523+
524+
# Verify it was recorded successfully
525+
loaded = store.load_analyses_for_task("public_method_test")
526+
assert loaded is not None
527+
assert loaded.task_id == "public_method_test"
528+
409529
store.close()

0 commit comments

Comments
 (0)