fix(db): add timeout=30s to all sqlite3.connect() calls across app

Watcher, REST endpoints, services (search, incidents, blocklist),
MCP server, context retriever, embedder, glean_scheduler, and
doc_upload all used the default 5-second SQLite busy timeout.
During collect glean write phases, watcher flush threads were hitting
'database is locked' errors when the glean held the write lock longer
than 5 seconds.

All connections now use timeout=30.0, matching the pipeline fix
from commit ee39ffb. No logic changes.
This commit is contained in:
pyr0ball 2026-05-26 23:12:48 -07:00
parent ee39ffbd44
commit 854818ca1a
14 changed files with 356 additions and 33 deletions

308
.nfs0000000000bbcf52000002e7 Executable file
View file

@ -0,0 +1,308 @@
#!/usr/bin/env bash
# manage.sh — Turnstone diagnostic intelligence layer
# Usage: ./manage.sh <command> [args]
set -euo pipefail
# Only emit color codes when stdout is a real terminal
if [[ -t 1 ]]; then
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
else
RED=''; GREEN=''; YELLOW=''; BLUE=''; NC=''
fi
info() { echo -e "${BLUE}[turnstone]${NC} $*"; }
success() { echo -e "${GREEN}[turnstone]${NC} $*"; }
warn() { echo -e "${YELLOW}[turnstone]${NC} $*"; }
error() { echo -e "${RED}[turnstone]${NC} $*" >&2; exit 1; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
API_PORT=8534 # FastAPI: serves REST API + built Vue SPA
VITE_PORT=5174 # Vite HMR port in dev mode (proxies /api → 8534)
LOG_DIR="log"
API_PID_FILE=".turnstone-api.pid"
DB="${TURNSTONE_DB:-${SCRIPT_DIR}/data/turnstone.db}"
CONDA_BASE="${CONDA_BASE:-/devl/miniconda3}"
PYTHON="${CONDA_BASE}/envs/cf/bin/python"
# ── Helpers ───────────────────────────────────────────────────────────────────
_is_alive() {
local pid_file="$1"
[[ -f "$pid_file" ]] && kill -0 "$(<"$pid_file")" 2>/dev/null
}
_kill_pid_file() {
local pid_file="$1" label="$2"
if [[ -f "$pid_file" ]]; then
local pid
pid=$(<"$pid_file")
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" && rm -f "$pid_file"
success "$label stopped (PID $pid)."
else
warn "Stale PID file for $label (PID $pid not running). Cleaning up."
rm -f "$pid_file"
fi
else
warn "$label not running."
fi
}
_wait_for_port() {
local port="$1" label="$2" pid_file="$3"
for _i in $(seq 1 20); do
sleep 0.5
(echo "" >/dev/tcp/127.0.0.1/"$port") 2>/dev/null && return 0
if ! _is_alive "$pid_file"; then
rm -f "$pid_file"
error "$label died during startup. Check ${LOG_DIR}/api.log"
fi
done
error "$label did not bind to port $port within 10 s."
}
# ── Usage ─────────────────────────────────────────────────────────────────────
usage() {
echo ""
echo -e " ${BLUE}Turnstone — Diagnostic Log Intelligence${NC}"
echo ""
echo " Usage: ./manage.sh <command> [args]"
echo ""
echo " Production-like (built SPA + uvicorn):"
echo -e " ${GREEN}start${NC} Build Vue SPA, start FastAPI + SPA on :${API_PORT}"
echo -e " ${GREEN}stop${NC} Stop the server"
echo -e " ${GREEN}restart${NC} Stop then start"
echo -e " ${GREEN}status${NC} Show running process"
echo -e " ${GREEN}logs${NC} Tail server log"
echo -e " ${GREEN}open${NC} Open UI in browser"
echo ""
echo " Development (hot-reload):"
echo -e " ${GREEN}dev${NC} uvicorn --reload (:${API_PORT}) + Vite HMR (:${VITE_PORT})"
echo ""
echo " Data:"
echo -e " ${GREEN}ingest PATH [DB]${NC} Ingest a log file or corpus directory"
echo -e " ${GREEN}ingest-plex [HOST]${NC} Pull Plex log from Cass (or HOST) and ingest"
echo -e " ${GREEN}ingest-qbit [HOST]${NC} Pull qBittorrent log locally or from HOST via SSH"
echo -e " ${GREEN}build-fts${NC} Rebuild the FTS search index"
echo ""
echo " Tests:"
echo -e " ${GREEN}test [args]${NC} Run pytest suite"
echo ""
echo " DB: ${DB}"
echo " Conda env: cf"
echo ""
echo " Examples:"
echo " ./manage.sh start"
echo " ./manage.sh dev"
echo " ./manage.sh ingest corpus/raw/"
echo " ./manage.sh ingest corpus/raw/ data/custom.db"
echo ""
}
# ── Commands ──────────────────────────────────────────────────────────────────
CMD="${1:-help}"
shift || true
case "$CMD" in
start)
if _is_alive "$API_PID_FILE"; then
warn "Already running (PID $(<"$API_PID_FILE")) — use 'restart' to rebuild."
exit 0
fi
mkdir -p "$LOG_DIR" data
info "Building Vue SPA…"
(cd web && npm run build) 2>&1 | tee "${LOG_DIR}/build.log" | grep -E "built in|error" || true
success "SPA built → web/dist/"
info "Starting on port ${API_PORT}…"
TURNSTONE_DB="$DB" nohup "$PYTHON" -m uvicorn app.rest:app \
--host 0.0.0.0 --port "$API_PORT" \
>> "${LOG_DIR}/api.log" 2>&1 &
echo $! > "$API_PID_FILE"
_wait_for_port "$API_PORT" "Turnstone" "$API_PID_FILE"
success "Running → http://localhost:${API_PORT} (PID $(<"$API_PID_FILE"))"
;;
stop)
_kill_pid_file "$API_PID_FILE" "Turnstone"
;;
restart)
bash "$0" stop
exec bash "$0" start
;;
status)
echo ""
if _is_alive "$API_PID_FILE"; then
success "Turnstone RUNNING PID $(<"$API_PID_FILE") → http://localhost:${API_PORT}"
else
echo -e " Turnstone ${RED}STOPPED${NC}"
fi
echo ""
;;
logs)
tail -f "${LOG_DIR}/api.log"
;;
open)
URL="http://localhost:${API_PORT}"
info "Opening ${URL}"
if command -v xdg-open &>/dev/null; then xdg-open "$URL"
elif command -v open &>/dev/null; then open "$URL"
else echo "$URL"
fi
;;
dev)
DEV_API_PID=".turnstone-dev-api.pid"
mkdir -p "$LOG_DIR" data
if _is_alive "$DEV_API_PID"; then
warn "Dev API already running (PID $(<"$DEV_API_PID"))"
else
info "Starting uvicorn --reload on port ${API_PORT}…"
TURNSTONE_DB="$DB" nohup "$PYTHON" -m uvicorn app.rest:app \
--host 0.0.0.0 --port "$API_PORT" --reload \
>> "${LOG_DIR}/api.log" 2>&1 &
echo $! > "$DEV_API_PID"
_wait_for_port "$API_PORT" "FastAPI (dev)" "$DEV_API_PID"
success "API (hot-reload) → http://localhost:${API_PORT}"
fi
_cleanup_dev() {
local pid
pid=$(<"$DEV_API_PID" 2>/dev/null) || true
[[ -n "${pid:-}" ]] && kill "$pid" 2>/dev/null && rm -f "$DEV_API_PID"
info "Dev servers stopped."
}
trap _cleanup_dev EXIT INT TERM
info "Starting Vite HMR on port ${VITE_PORT}…"
success "Frontend (HMR) → http://localhost:${VITE_PORT}"
(cd web && npm run dev -- --port "$VITE_PORT")
;;
ingest)
if [[ $# -lt 1 ]]; then
error "Usage: ./manage.sh ingest <file_or_dir> [DB_PATH]"
fi
info "Ingesting $1 → ${2:-$DB}…"
"$PYTHON" scripts/ingest_corpus.py "$1" "${2:-$DB}"
;;
ingest-plex)
PLEX_HOST="${1:-cass}"
PLEX_LOG_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Logs"
TMP_DIR="/tmp/turnstone-plex-$$"
mkdir -p "$TMP_DIR"
info "Listing Plex logs on ${PLEX_HOST}…"
# Get list of all rotated + active Plex logs
mapfile -t REMOTE_LOGS < <(ssh "$PLEX_HOST" \
"ls '${PLEX_LOG_DIR}'/Plex\ Media\ Server*.log 2>/dev/null") \
|| { rm -rf "$TMP_DIR"; error "SSH to ${PLEX_HOST} failed."; }
if [[ ${#REMOTE_LOGS[@]} -eq 0 ]]; then
rm -rf "$TMP_DIR"
error "No Plex logs found on ${PLEX_HOST} at ${PLEX_LOG_DIR}"
fi
for remote_path in "${REMOTE_LOGS[@]}"; do
# Plex Media Server.1.log → cass-plex_media_server.1.log
local_name="${PLEX_HOST}-$(basename "$remote_path" | tr ' ' '_' | tr '[:upper:]' '[:lower:]')"
local_path="${TMP_DIR}/${local_name}"
info " ← $(basename "$remote_path")"
ssh "$PLEX_HOST" "cat '${remote_path}'" > "$local_path"
done
info "Ingesting ${#REMOTE_LOGS[@]} log file(s) into ${DB}…"
for f in "$TMP_DIR"/*.log; do
"$PYTHON" scripts/ingest_corpus.py "$f" "$DB"
done
rm -rf "$TMP_DIR"
info "Done. Restarting server…"
exec bash "$0" restart
;;
ingest-qbit)
QBIT_HOST="${1:-}"
# Default log locations in priority order
QBIT_LOG_PATHS=(
"$HOME/.local/share/qBittorrent/logs/qbittorrent.log"
"$HOME/.config/qBittorrent/logs/qbittorrent.log"
"/var/log/qbittorrent/qbittorrent.log"
)
TMP_DIR="/tmp/turnstone-qbit-$$"
mkdir -p "$TMP_DIR"
if [[ -n "$QBIT_HOST" ]]; then
info "Fetching qBittorrent log from ${QBIT_HOST}…"
REMOTE_LOG=""
for p in "${QBIT_LOG_PATHS[@]}"; do
if ssh "$QBIT_HOST" "test -f '$p'" 2>/dev/null; then
REMOTE_LOG="$p"
break
fi
done
if [[ -z "$REMOTE_LOG" ]]; then
rm -rf "$TMP_DIR"
error "No qBittorrent log found on ${QBIT_HOST}. Tried: ${QBIT_LOG_PATHS[*]}"
fi
local_name="${QBIT_HOST}-qbittorrent.log"
ssh "$QBIT_HOST" "cat '$REMOTE_LOG'" > "${TMP_DIR}/${local_name}"
info " ← ${REMOTE_LOG} (${QBIT_HOST})"
else
LOCAL_LOG=""
for p in "${QBIT_LOG_PATHS[@]}"; do
if [[ -f "$p" ]]; then
LOCAL_LOG="$p"
break
fi
done
if [[ -z "$LOCAL_LOG" ]]; then
rm -rf "$TMP_DIR"
error "No qBittorrent log found locally. Tried: ${QBIT_LOG_PATHS[*]}"
fi
cp "$LOCAL_LOG" "${TMP_DIR}/qbittorrent.log"
info " ← ${LOCAL_LOG}"
fi
info "Ingesting into ${DB}…"
"$PYTHON" scripts/ingest_corpus.py "${TMP_DIR}"/*.log "$DB"
rm -rf "$TMP_DIR"
info "Done. Restarting server…"
exec bash "$0" restart
;;
build-fts)
info "Rebuilding FTS index for ${DB}…"
TURNSTONE_DB="$DB" "$PYTHON" scripts/build_fts_index.py "$DB"
success "FTS index rebuilt."
;;
test)
info "Running test suite…"
PYTEST="${CONDA_BASE}/envs/cf/bin/pytest"
[[ -x "$PYTEST" ]] || error "pytest not found in cf env at ${PYTEST}"
TURNSTONE_DB=":memory:" "$PYTEST" tests/ -v "$@"
;;
help|--help|-h)
usage
;;
*)
error "Unknown command: ${CMD}. Run './manage.sh help' for usage."
;;
esac

View file

@ -45,7 +45,7 @@ def embed_chunks(
if embedder is None: if embedder is None:
return 0 return 0
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row

View file

@ -37,7 +37,7 @@ class RetrievedContext:
def get_relevant_facts(db_path: Path, query: str) -> list[dict[str, str]]: def get_relevant_facts(db_path: Path, query: str) -> list[dict[str, str]]:
"""Keyword match against context_facts. Always runs — Free tier.""" """Keyword match against context_facts. Always runs — Free tier."""
try: try:
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
keywords = [w.lower() for w in query.split() if len(w) > 2] keywords = [w.lower() for w in query.split() if len(w) > 2]
@ -92,7 +92,7 @@ def _search_chunks_vector(
return [] return []
try: try:
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
rows = conn.execute( rows = conn.execute(
@ -127,7 +127,7 @@ def _search_chunks_vector(
def _search_chunks_keyword(db_path: Path, query: str) -> list[dict[str, str]]: def _search_chunks_keyword(db_path: Path, query: str) -> list[dict[str, str]]:
"""LIKE-based keyword search across context_chunks. Fallback when no embedder.""" """LIKE-based keyword search across context_chunks. Fallback when no embedder."""
try: try:
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
keywords = [w.lower() for w in query.split() if len(w) > 2][:5] keywords = [w.lower() for w in query.split() if len(w) > 2][:5]

View file

@ -25,7 +25,7 @@ def glean_upload(db_path: Path, filename: str, content: bytes) -> dict[str, Any]
for fact in facts: for fact in facts:
add_fact(db_path, fact.category, fact.key, fact.value, source="upload") add_fact(db_path, fact.category, fact.key, fact.value, source="upload")
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
for i, chunk_text in enumerate(chunks): for i, chunk_text in enumerate(chunks):
conn.execute( conn.execute(

View file

@ -59,7 +59,7 @@ def _make_entry_id(source_id: str, seq: int, text: str) -> str:
def _write_entry(db_path: Path, entry: RetrievedEntry) -> None: def _write_entry(db_path: Path, entry: RetrievedEntry) -> None:
with sqlite3.connect(db_path) as conn: with sqlite3.connect(db_path, timeout=30.0) as conn:
conn.execute( conn.execute(
""" """
INSERT OR IGNORE INTO log_entries INSERT OR IGNORE INTO log_entries

View file

@ -59,7 +59,7 @@ def _ensure_index() -> None:
return return
try: try:
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH), timeout=30.0)
count = conn.execute("SELECT COUNT(*) FROM log_fts").fetchone()[0] count = conn.execute("SELECT COUNT(*) FROM log_fts").fetchone()[0]
conn.close() conn.close()
if count > 0: if count > 0:

View file

@ -509,7 +509,7 @@ def list_configured_sources() -> dict:
@router.delete("/api/sources/{source_id}") @router.delete("/api/sources/{source_id}")
def delete_source(source_id: str) -> dict: def delete_source(source_id: str) -> dict:
"""Delete all log entries (and FTS index rows) for a given source.""" """Delete all log entries (and FTS index rows) for a given source."""
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
try: try:
conn.execute("DELETE FROM log_fts WHERE source_id = ?", (source_id,)) conn.execute("DELETE FROM log_fts WHERE source_id = ?", (source_id,))
@ -616,7 +616,7 @@ def glean_batch(payload: BatchGleanRequest, background_tasks: BackgroundTasks) -
""" """
if not payload.entries: if not payload.entries:
return {"gleaned": 0} return {"gleaned": 0}
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.executemany( conn.executemany(
""" """
@ -710,7 +710,7 @@ async def glean_wazuh_alert(
compiled = load_compiled_patterns(PATTERN_FILE) compiled = load_compiled_patterns(PATTERN_FILE)
entries = list(_parse_wazuh(iter([json.dumps(alert)]), sid, compiled, ingest_time)) entries = list(_parse_wazuh(iter([json.dumps(alert)]), sid, compiled, ingest_time))
if entries: if entries:
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.executemany( conn.executemany(
""" """
@ -892,7 +892,7 @@ def glean_tautulli(
compiled = _compiled_patterns compiled = _compiled_patterns
entry = _parse_tautulli(payload, compiled) entry = _parse_tautulli(payload, compiled)
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
try: try:
_tautulli_write_entry(conn, entry) _tautulli_write_entry(conn, entry)

View file

@ -172,7 +172,7 @@ def run_scan(
now = _now_iso() now = _now_iso()
count = 0 count = 0
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
try: try:
rows = conn.execute( rows = conn.execute(
f"SELECT id, text FROM log_entries WHERE source_id IN ({placeholders})", f"SELECT id, text FROM log_entries WHERE source_id IN ({placeholders})",
@ -226,7 +226,7 @@ def list_candidates(
status: str | None = None, status: str | None = None,
device_ip: str | None = None, device_ip: str | None = None,
) -> list[BlocklistCandidate]: ) -> list[BlocklistCandidate]:
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
try: try:
query = f"{_CANDIDATE_SELECT} WHERE 1=1" query = f"{_CANDIDATE_SELECT} WHERE 1=1"
params: list = [] params: list = []
@ -255,7 +255,7 @@ def _get_candidate(conn: sqlite3.Connection, candidate_id: str) -> BlocklistCand
def get_candidate(db_path: Path, candidate_id: str) -> BlocklistCandidate: def get_candidate(db_path: Path, candidate_id: str) -> BlocklistCandidate:
"""Fetch a single candidate by ID. Raises KeyError if not found.""" """Fetch a single candidate by ID. Raises KeyError if not found."""
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
try: try:
return _get_candidate(conn, candidate_id) return _get_candidate(conn, candidate_id)
finally: finally:
@ -265,7 +265,7 @@ def get_candidate(db_path: Path, candidate_id: str) -> BlocklistCandidate:
def update_candidate_status(db_path: Path, candidate_id: str, new_status: str) -> BlocklistCandidate: def update_candidate_status(db_path: Path, candidate_id: str, new_status: str) -> BlocklistCandidate:
if new_status not in _VALID_STATUSES: if new_status not in _VALID_STATUSES:
raise ValueError(f"Invalid status {new_status!r}. Must be one of {_VALID_STATUSES}") raise ValueError(f"Invalid status {new_status!r}. Must be one of {_VALID_STATUSES}")
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
try: try:
conn.execute("UPDATE blocklist_candidates SET status=? WHERE id=?", (new_status, candidate_id)) conn.execute("UPDATE blocklist_candidates SET status=? WHERE id=?", (new_status, candidate_id))
conn.commit() conn.commit()
@ -275,7 +275,7 @@ def update_candidate_status(db_path: Path, candidate_id: str, new_status: str) -
def mark_pushed(db_path: Path, candidate_id: str) -> BlocklistCandidate: def mark_pushed(db_path: Path, candidate_id: str) -> BlocklistCandidate:
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
try: try:
conn.execute( conn.execute(
"UPDATE blocklist_candidates SET status='pushed', pushed_at=? WHERE id=?", "UPDATE blocklist_candidates SET status='pushed', pushed_at=? WHERE id=?",
@ -288,7 +288,7 @@ def mark_pushed(db_path: Path, candidate_id: str) -> BlocklistCandidate:
def mark_unblocked(db_path: Path, candidate_id: str) -> BlocklistCandidate: def mark_unblocked(db_path: Path, candidate_id: str) -> BlocklistCandidate:
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
try: try:
conn.execute("UPDATE blocklist_candidates SET status='unblocked' WHERE id=?", (candidate_id,)) conn.execute("UPDATE blocklist_candidates SET status='unblocked' WHERE id=?", (candidate_id,))
conn.commit() conn.commit()

View file

@ -72,7 +72,7 @@ def _fetch_resolved_incidents(db_path: Path) -> list[str]:
Returns an empty list on any error (missing table, connection failure, etc.). Returns an empty list on any error (missing table, connection failure, etc.).
""" """
try: try:
with sqlite3.connect(str(db_path)) as conn: with sqlite3.connect(str(db_path), timeout=30.0) as conn:
cursor = conn.execute( cursor = conn.execute(
"SELECT label, notes FROM incidents WHERE ended_at IS NOT NULL LIMIT 200" "SELECT label, notes FROM incidents WHERE ended_at IS NOT NULL LIMIT 200"
) )

View file

@ -57,7 +57,7 @@ def create_incident(
created_at=now_iso(), created_at=now_iso(),
severity=severity, severity=severity,
) )
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.execute( conn.execute(
"INSERT INTO incidents (id, label, issue_type, started_at, ended_at, notes, created_at, severity) " "INSERT INTO incidents (id, label, issue_type, started_at, ended_at, notes, created_at, severity) "
@ -71,7 +71,7 @@ def create_incident(
def list_incidents(db_path: Path) -> list[Incident]: def list_incidents(db_path: Path) -> list[Incident]:
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
rows = conn.execute( rows = conn.execute(
@ -82,7 +82,7 @@ def list_incidents(db_path: Path) -> list[Incident]:
def get_incident(db_path: Path, incident_id: str) -> Incident | None: def get_incident(db_path: Path, incident_id: str) -> Incident | None:
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
row = conn.execute( row = conn.execute(
@ -93,7 +93,7 @@ def get_incident(db_path: Path, incident_id: str) -> Incident | None:
def delete_incident(db_path: Path, incident_id: str) -> bool: def delete_incident(db_path: Path, incident_id: str) -> bool:
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
cur = conn.execute("DELETE FROM incidents WHERE id = ?", (incident_id,)) cur = conn.execute("DELETE FROM incidents WHERE id = ?", (incident_id,))
conn.commit() conn.commit()
@ -186,7 +186,7 @@ def store_bundle(db_path: Path, bundle: dict) -> ReceivedBundle:
entry_count=len(bundle.get("log_entries", [])), entry_count=len(bundle.get("log_entries", [])),
bundle_json=json.dumps(bundle), bundle_json=json.dumps(bundle),
) )
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.execute( conn.execute(
"INSERT INTO received_bundles " "INSERT INTO received_bundles "
@ -201,7 +201,7 @@ def store_bundle(db_path: Path, bundle: dict) -> ReceivedBundle:
def list_bundles(db_path: Path) -> list[ReceivedBundle]: def list_bundles(db_path: Path) -> list[ReceivedBundle]:
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
rows = conn.execute( rows = conn.execute(
@ -213,7 +213,7 @@ def list_bundles(db_path: Path) -> list[ReceivedBundle]:
def get_bundle(db_path: Path, bundle_id: str) -> ReceivedBundle | None: def get_bundle(db_path: Path, bundle_id: str) -> ReceivedBundle | None:
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
row = conn.execute( row = conn.execute(

View file

@ -30,7 +30,7 @@ def build_fts_index(db_path: Path) -> None:
Drops and recreates the table if the schema is stale (missing sequence column). Drops and recreates the table if the schema is stale (missing sequence column).
""" """
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
# Check whether existing table has the sequence column; rebuild if not. # Check whether existing table has the sequence column; rebuild if not.
@ -98,7 +98,7 @@ def search(
or_mode: bool = False, or_mode: bool = False,
) -> list[SearchResult]: ) -> list[SearchResult]:
"""Full-text search with optional filters. Returns results ranked by relevance.""" """Full-text search with optional filters. Returns results ranked by relevance."""
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@ -181,7 +181,7 @@ def entries_in_window(
(e.g. network-syslog) don't crowd out lower-volume but more interesting ones. (e.g. network-syslog) don't crowd out lower-volume but more interesting ones.
Errors/warnings are ranked first within each source partition. Errors/warnings are ranked first within each source partition.
""" """
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@ -275,7 +275,7 @@ def recent_source_errors(
Bypasses FTS ranking so text content doesn't affect which errors surface. Bypasses FTS ranking so text content doesn't affect which errors surface.
Used by diagnose when FTS keyword search returns nothing for a known source. Used by diagnose when FTS keyword search returns nothing for a known source.
""" """
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@ -328,7 +328,7 @@ def recent_source_errors(
def list_sources(db_path: Path) -> list[dict]: def list_sources(db_path: Path) -> list[dict]:
"""Return distinct sources with entry counts and time ranges.""" """Return distinct sources with entry counts and time ranges."""
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
rows = conn.execute(""" rows = conn.execute("""
SELECT SELECT
@ -381,7 +381,7 @@ def stats_summary(db_path: Path, window_hours: int = 24, severity_overrides: lis
""" """
rules = _compile_overrides(severity_overrides or []) rules = _compile_overrides(severity_overrides or [])
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row

View file

@ -49,7 +49,7 @@ def get_state() -> IngestState:
def _query_matched_since(db_path: Path, since: str | None) -> list[dict]: def _query_matched_since(db_path: Path, since: str | None) -> list[dict]:
"""Return entries with non-empty matched_patterns, optionally filtered by ingest_time.""" """Return entries with non-empty matched_patterns, optionally filtered by ingest_time."""
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
try: try:
if since: if since:

View file

@ -111,7 +111,7 @@ class WatchSource:
patterns = load_patterns(self.pattern_file) patterns = load_patterns(self.pattern_file)
compiled = _compile(patterns) compiled = _compile(patterns)
conn = sqlite3.connect(str(self.db_path)) conn = sqlite3.connect(str(self.db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(_SCHEMA) conn.executescript(_SCHEMA)
conn.commit() conn.commit()

View file

@ -99,6 +99,21 @@ else
echo "navi: unreachable, skipping docker logs" echo "navi: unreachable, skipping docker logs"
fi fi
# ── Navi qBittorrent app logs (volume-mounted files, not in docker logs) ──────
# qBit writes rich per-torrent events to a file inside the compose volume.
# These are NOT captured by `docker logs` — must be pulled directly.
QBIT_LOG_BASE="/opt/containers/arr"
for instance in qbit-tb0 qbit-tb1 qbit-tb2; do
remote_log="${QBIT_LOG_BASE}/${instance}/qBittorrent/logs/qbittorrent.log"
local_out="${NAVI_DIR}/${instance}-app.log"
if ssh ${SSH_OPTS} navi "test -f '${remote_log}'" 2>/dev/null; then
ssh ${SSH_OPTS} navi "cat '${remote_log}'" > "${local_out}" 2>/dev/null || : > "${local_out}"
else
: > "${local_out}"
fi
done
echo "navi qbit app logs: $(cat "${NAVI_DIR}"/qbit-tb*.log 2>/dev/null | wc -l) lines"
# ── Strahl Docker containers ────────────────────────────────────────────────── # ── Strahl Docker containers ──────────────────────────────────────────────────
STRAHL_DIR="${DATA_DIR}/docker-strahl" STRAHL_DIR="${DATA_DIR}/docker-strahl"
mkdir -p "${STRAHL_DIR}" mkdir -p "${STRAHL_DIR}"