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:
parent
ee39ffbd44
commit
854818ca1a
14 changed files with 356 additions and 33 deletions
308
.nfs0000000000bbcf52000002e7
Executable file
308
.nfs0000000000bbcf52000002e7
Executable 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
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue