Skip to content

Commit d014c1e

Browse files
Brian KrafftCopilot
andcommitted
fix(3.2): address /8eyes findings — delegation scope, shared lock, LIKE escape, JSON safety
F1: Revert save_record/load_record/load_all delegation from SkillStore to SkillRepository — SkillStore._upsert and _to_record persist/hydrate recent_analyses which SkillRepository intentionally omits (Epic 3.4). Simple CRUD (delete, count, exists) remains delegated. F2: SkillRepository now accepts an optional lock= parameter. SkillStore passes its own _mu so both share one mutex over the shared connection. F3: search() escapes %, _, \\ in the name filter before LIKE via ESCAPE. F4: json.loads(raw_snapshot) in both _to_record methods wrapped in try/except json.JSONDecodeError with fallback to {}. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c15acd4 commit d014c1e

3 files changed

Lines changed: 205 additions & 15 deletions

File tree

openspace/skill_engine/skill_repository.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,16 +136,20 @@ class SkillRepository:
136136
db_path: Path to the SQLite database file.
137137
conn: Optional existing SQLite connection (for embedding in SkillStore).
138138
If provided, the repository will NOT own or close this connection.
139+
lock: Optional :class:`threading.Lock` to use instead of creating a
140+
private one. When sharing a connection with another component
141+
(e.g. ``SkillStore``), pass the same lock to avoid dual-mutex.
139142
"""
140143

141144
def __init__(
142145
self,
143146
db_path: Optional[Path] = None,
144147
conn: Optional[sqlite3.Connection] = None,
148+
lock: Optional[threading.Lock] = None,
145149
) -> None:
146150
self._owns_conn = conn is None
147151
self._closed = False
148-
self._mu = threading.Lock()
152+
self._mu = lock if lock is not None else threading.Lock()
149153

150154
if conn is not None:
151155
self._conn = conn
@@ -349,8 +353,9 @@ def search(
349353
conditions.append("sr.is_active = 1")
350354

351355
if name is not None:
352-
conditions.append("sr.name LIKE ?")
353-
params.append(f"%{name}%")
356+
escaped = name.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
357+
conditions.append("sr.name LIKE ? ESCAPE '\\'")
358+
params.append(f"%{escaped}%")
354359

355360
if category is not None:
356361
conditions.append("sr.category = ?")
@@ -533,7 +538,10 @@ def _to_record(conn: sqlite3.Connection, row: sqlite3.Row) -> SkillRecord:
533538
]
534539

535540
raw_snapshot = row["lineage_content_snapshot"] or "{}"
536-
snapshot: Dict[str, str] = json.loads(raw_snapshot)
541+
try:
542+
snapshot: Dict[str, str] = json.loads(raw_snapshot)
543+
except json.JSONDecodeError:
544+
snapshot = {}
537545

538546
lineage = SkillLineage(
539547
origin=SkillOrigin(row["lineage_origin"]),

openspace/skill_engine/store.py

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,9 @@ def __init__(self, db_path: Optional[Path] = None) -> None:
193193
self._conn = self._make_connection(read_only=False)
194194
self._init_db()
195195

196-
# CRUD repository (Epic 3.2) — delegates data-access operations
197-
self._repo = SkillRepository(conn=self._conn)
196+
# CRUD repository (Epic 3.2) — delegates simple CRUD operations.
197+
# Shares our lock to avoid dual-mutex on the same connection.
198+
self._repo = SkillRepository(conn=self._conn, lock=self._mu)
198199

199200
logger.debug(f"SkillStore ready at {self._db_path}")
200201

@@ -504,13 +505,38 @@ async def delete_record(self, skill_id: str) -> bool:
504505
# Sync write implementations (thread-safe via self._mu)
505506
@_db_retry()
506507
def _save_record_sync(self, record: SkillRecord) -> None:
508+
"""Persist a SkillRecord including its recent_analyses.
509+
510+
NOT delegated to SkillRepository — analyses persistence is
511+
handled by SkillStore._upsert until Epic 3.4 (AnalysisStore).
512+
"""
507513
self._ensure_open()
508-
self._repo.save(record)
514+
with self._mu:
515+
self._conn.execute("BEGIN")
516+
try:
517+
self._upsert(record)
518+
self._conn.commit()
519+
except Exception:
520+
self._conn.rollback()
521+
raise
509522

510523
@_db_retry()
511524
def _save_records_sync(self, records: List[SkillRecord]) -> None:
525+
"""Batch persist SkillRecords including their recent_analyses.
526+
527+
NOT delegated to SkillRepository — analyses persistence is
528+
handled by SkillStore._upsert until Epic 3.4 (AnalysisStore).
529+
"""
512530
self._ensure_open()
513-
self._repo.save_many(records)
531+
with self._mu:
532+
self._conn.execute("BEGIN")
533+
try:
534+
for r in records:
535+
self._upsert(r)
536+
self._conn.commit()
537+
except Exception:
538+
self._conn.rollback()
539+
raise
514540

515541
@_db_retry()
516542
def _record_analysis_sync(self, analysis: ExecutionAnalysis) -> None:
@@ -619,19 +645,41 @@ def _delete_record_sync(self, skill_id: str) -> bool:
619645
# Read API (sync, each call opens its own read-only conn)
620646
@_db_retry()
621647
def load_record(self, skill_id: str) -> Optional[SkillRecord]:
622-
"""Load a single :class:`SkillRecord` by id."""
623-
return self._repo.get(skill_id)
648+
"""Load a single :class:`SkillRecord` by id, including recent_analyses.
649+
650+
NOT delegated to SkillRepository — analysis hydration is
651+
handled by SkillStore._to_record until Epic 3.4 (AnalysisStore).
652+
"""
653+
with self._reader() as conn:
654+
row = conn.execute(
655+
"SELECT * FROM skill_records WHERE skill_id=?",
656+
(skill_id,),
657+
).fetchone()
658+
return self._to_record(conn, row) if row else None
624659

625660
@_db_retry()
626661
def load_all(self, *, active_only: bool = False) -> Dict[str, SkillRecord]:
627-
"""Load skill records, keyed by ``skill_id``.
662+
"""Load skill records, keyed by ``skill_id``, including recent_analyses.
663+
664+
NOT delegated to SkillRepository — analysis hydration is
665+
handled by SkillStore._to_record until Epic 3.4 (AnalysisStore).
628666
629667
Args:
630668
active_only: If True, only return records with ``is_active=True``.
631669
"""
632-
result = self._repo.list_all(active_only=active_only)
633-
logger.info(f"Loaded {len(result)} skill records (active_only={active_only})")
634-
return result
670+
with self._reader() as conn:
671+
if active_only:
672+
rows = conn.execute(
673+
"SELECT * FROM skill_records WHERE is_active=1"
674+
).fetchall()
675+
else:
676+
rows = conn.execute("SELECT * FROM skill_records").fetchall()
677+
result: Dict[str, SkillRecord] = {}
678+
for row in rows:
679+
rec = self._to_record(conn, row)
680+
result[rec.skill_id] = rec
681+
logger.info(f"Loaded {len(result)} skill records (active_only={active_only})")
682+
return result
635683

636684
@_db_retry()
637685
def load_active(self) -> Dict[str, SkillRecord]:
@@ -1240,7 +1288,10 @@ def _to_record(self, conn: sqlite3.Connection, row: sqlite3.Row) -> SkillRecord:
12401288
# Deserialize content_snapshot: stored as JSON dict
12411289
# mapping relative file paths to their text content
12421290
raw_snapshot = row["lineage_content_snapshot"] or "{}"
1243-
snapshot: Dict[str, str] = json.loads(raw_snapshot)
1291+
try:
1292+
snapshot: Dict[str, str] = json.loads(raw_snapshot)
1293+
except json.JSONDecodeError:
1294+
snapshot = {}
12441295

12451296
lineage = SkillLineage(
12461297
origin=SkillOrigin(row["lineage_origin"]),

tests/test_skill_repository.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,3 +388,134 @@ def test_close_prevents_further_operations(self, repo):
388388
repo.close()
389389
with pytest.raises(RuntimeError):
390390
repo.get("anything")
391+
392+
393+
# ═══════════════════════════════════════════════════════════════════════
394+
# F2: Shared Lock (dual-mutex fix)
395+
# ═══════════════════════════════════════════════════════════════════════
396+
397+
398+
class TestSharedLock:
399+
"""When given an external lock, SkillRepository should use it."""
400+
401+
def test_uses_provided_lock(self, tmp_path):
402+
from openspace.skill_engine.skill_repository import SkillRepository
403+
import threading
404+
405+
external_lock = threading.Lock()
406+
db_path = tmp_path / "shared_lock.db"
407+
repo = SkillRepository(db_path=db_path, lock=external_lock)
408+
assert repo._mu is external_lock
409+
repo.close()
410+
411+
def test_creates_own_lock_when_none_provided(self, tmp_path):
412+
from openspace.skill_engine.skill_repository import SkillRepository
413+
import threading
414+
415+
db_path = tmp_path / "own_lock.db"
416+
repo = SkillRepository(db_path=db_path)
417+
assert isinstance(repo._mu, type(threading.Lock()))
418+
repo.close()
419+
420+
def test_shared_conn_and_lock(self, tmp_path):
421+
"""Shared connection + shared lock should work without deadlock."""
422+
from openspace.skill_engine.skill_repository import SkillRepository
423+
import sqlite3
424+
import threading
425+
426+
db_path = tmp_path / "shared.db"
427+
conn = sqlite3.connect(str(db_path), check_same_thread=False)
428+
conn.row_factory = sqlite3.Row
429+
lock = threading.Lock()
430+
431+
repo = SkillRepository(conn=conn, lock=lock)
432+
# Init tables manually since shared conn skips _init_db
433+
repo._owns_conn = True # temporarily to init
434+
repo._db_path = db_path
435+
repo._init_db()
436+
repo._owns_conn = False
437+
repo._db_path = Path(":shared:")
438+
439+
record = _make_record()
440+
repo.save(record)
441+
loaded = repo.get(record.skill_id)
442+
assert loaded is not None
443+
assert loaded.skill_id == record.skill_id
444+
445+
repo.close()
446+
conn.close()
447+
448+
449+
# ═══════════════════════════════════════════════════════════════════════
450+
# F3: LIKE Wildcard Escaping
451+
# ═══════════════════════════════════════════════════════════════════════
452+
453+
454+
class TestSearchEscaping:
455+
"""search() must escape SQL LIKE wildcards in user input."""
456+
457+
def test_percent_in_name_does_not_match_broadly(self, repo):
458+
repo.save_many([
459+
_make_record(skill_id="s1", name="100%_done"),
460+
_make_record(skill_id="s2", name="100_other_tasks"),
461+
_make_record(skill_id="s3", name="something_else"),
462+
])
463+
464+
results = repo.search(name="100%_done")
465+
assert len(results) == 1
466+
assert results[0].skill_id == "s1"
467+
468+
def test_underscore_in_name_is_literal(self, repo):
469+
repo.save_many([
470+
_make_record(skill_id="s1", name="my_skill"),
471+
_make_record(skill_id="s2", name="myXskill"),
472+
])
473+
474+
# Without escaping, "_" would match any single char including "X"
475+
results = repo.search(name="my_skill")
476+
assert len(results) == 1
477+
assert results[0].skill_id == "s1"
478+
479+
def test_backslash_in_name_is_literal(self, repo):
480+
repo.save(_make_record(skill_id="s1", name="path\\to\\skill"))
481+
results = repo.search(name="path\\to")
482+
assert len(results) == 1
483+
484+
485+
# ═══════════════════════════════════════════════════════════════════════
486+
# F4: JSON Safety in _to_record
487+
# ═══════════════════════════════════════════════════════════════════════
488+
489+
490+
class TestJsonSafety:
491+
"""_to_record should survive malformed JSON in lineage_content_snapshot."""
492+
493+
def test_malformed_snapshot_falls_back_to_empty_dict(self, repo):
494+
"""Insert a record, corrupt the JSON, then load it."""
495+
record = _make_record(skill_id="corrupt1", name="corrupt_test")
496+
repo.save(record)
497+
498+
# Directly corrupt the snapshot column
499+
repo._conn.execute(
500+
"UPDATE skill_records SET lineage_content_snapshot = ? WHERE skill_id = ?",
501+
("NOT VALID JSON {{{", "corrupt1"),
502+
)
503+
repo._conn.commit()
504+
505+
loaded = repo.get("corrupt1")
506+
assert loaded is not None
507+
assert loaded.lineage.content_snapshot == {}
508+
509+
def test_empty_snapshot_falls_back_to_empty_dict(self, repo):
510+
record = _make_record(skill_id="empty1", name="empty_test")
511+
repo.save(record)
512+
513+
repo._conn.execute(
514+
"UPDATE skill_records SET lineage_content_snapshot = '' WHERE skill_id = ?",
515+
("empty1",),
516+
)
517+
repo._conn.commit()
518+
519+
loaded = repo.get("empty1")
520+
assert loaded is not None
521+
assert loaded.lineage.content_snapshot == {}

0 commit comments

Comments
 (0)