Vaibhav Pharmacy commited on
Commit
96bdeb2
·
1 Parent(s): 5db7064

Harden all edge cases: auto-reconnect with health check, thread-safe connection pool, scoped artist CTEs (137K→30 row scan), ttl_cache on stats/filters, invalidate_cache on mutations, safe is_watched cast, remove test files

Browse files
Files changed (6) hide show
  1. .gitignore +2 -0
  2. api/main.py +39 -20
  3. collector/db.py +38 -10
  4. test_db.py +0 -10
  5. test_perf.py +0 -53
  6. test_perf_artist.py +0 -65
.gitignore CHANGED
@@ -48,3 +48,5 @@ frontend/.swc/
48
  frontend/public/logo.png
49
  frontend/src/app/favicon.ico
50
  db/.fuse_hidden*
 
 
 
48
  frontend/public/logo.png
49
  frontend/src/app/favicon.ico
50
  db/.fuse_hidden*
51
+ *.pyc
52
+ test_*.py
api/main.py CHANGED
@@ -18,6 +18,7 @@ from fastapi.middleware.cors import CORSMiddleware
18
  from pydantic import BaseModel
19
  from db import get_connection, init_db
20
  from metrics import compute_artist_metrics, enrich_song_with_metrics, enrich_viral_alert, enrich_songs_bulk, enrich_viral_alerts_bulk
 
21
 
22
  app = FastAPI(title="Music Intelligence Dashboard API", version="1.0.0")
23
 
@@ -1029,6 +1030,10 @@ def get_spotify_artists(
1029
  @app.get("/api/filters")
1030
  def get_filter_options():
1031
  """Get available genre and region options for filter dropdowns."""
 
 
 
 
1032
  conn = get_connection()
1033
  genres = [r[0] for r in conn.execute(
1034
  "SELECT DISTINCT genre FROM artists WHERE genre IS NOT NULL ORDER BY genre"
@@ -1052,17 +1057,22 @@ def get_artist_detail(artist_id: str, platform: str = Query("youtube")):
1052
  conn.close()
1053
  raise HTTPException(status_code=404, detail="Artist not found")
1054
 
1055
- # Get songs for this platform
1056
  songs = conn.execute("""
1057
- WITH latest_snaps AS (
1058
- SELECT song_id, MAX(id) as max_id
1059
- FROM play_snapshots
1060
- GROUP BY song_id
 
 
 
 
1061
  ),
1062
  first_snaps AS (
1063
- SELECT song_id, MIN(id) as min_id
1064
- FROM play_snapshots
1065
- GROUP BY song_id
 
1066
  )
1067
  SELECT
1068
  s.*,
@@ -1081,7 +1091,7 @@ def get_artist_detail(artist_id: str, platform: str = Query("youtube")):
1081
  LEFT JOIN play_snapshots fs_data ON fs.min_id = fs_data.id
1082
  WHERE s.artist_id = ? AND s.platform = ?
1083
  ORDER BY latest_play_count DESC NULLS LAST
1084
- """, (artist_id, platform)).fetchall()
1085
 
1086
  # Get viral alerts for this artist
1087
  alerts = conn.execute("""
@@ -1123,17 +1133,20 @@ def get_spotify_artist_detail(artist_id: str):
1123
  raise HTTPException(status_code=404, detail="Spotify artist not found")
1124
 
1125
  songs = conn.execute("""
1126
- WITH latest_snaps AS (
1127
- SELECT song_id, MAX(id) as max_id
1128
- FROM play_snapshots
1129
- WHERE platform = 'spotify'
1130
- GROUP BY song_id
 
 
 
1131
  ),
1132
  first_snaps AS (
1133
- SELECT song_id, MIN(id) as min_id
1134
- FROM play_snapshots
1135
- WHERE platform = 'spotify'
1136
- GROUP BY song_id
1137
  )
1138
  SELECT
1139
  s.*,
@@ -1147,7 +1160,7 @@ def get_spotify_artist_detail(artist_id: str):
1147
  LEFT JOIN play_snapshots fs_data ON fs.min_id = fs_data.id
1148
  WHERE s.artist_id = ? AND s.platform = 'spotify'
1149
  ORDER BY latest_play_count DESC NULLS LAST
1150
- """, (artist_id,)).fetchall()
1151
 
1152
  alerts = conn.execute("""
1153
  SELECT
@@ -1197,10 +1210,11 @@ def toggle_watchlist(artist_id: str):
1197
  conn.close()
1198
  raise HTTPException(status_code=404, detail="Artist not found")
1199
 
1200
- new_status = 0 if artist["is_watched"] else 1
1201
  conn.execute("UPDATE artists SET is_watched = ? WHERE id = ?", (new_status, artist_id))
1202
  conn.commit()
1203
  conn.close()
 
1204
  return {"artist_id": artist_id, "is_watched": bool(new_status)}
1205
 
1206
 
@@ -1236,6 +1250,7 @@ def delete_artist(artist_id: str):
1236
  conn.execute("DELETE FROM artists WHERE id = ?", (artist_id,))
1237
  conn.commit()
1238
  conn.close()
 
1239
 
1240
  return {
1241
  "status": "deleted",
@@ -1249,6 +1264,10 @@ def delete_artist(artist_id: str):
1249
  @app.get("/api/stats")
1250
  def get_stats():
1251
  """Get overall dashboard statistics."""
 
 
 
 
1252
  conn = get_connection()
1253
  counts = conn.execute("""
1254
  SELECT
 
18
  from pydantic import BaseModel
19
  from db import get_connection, init_db
20
  from metrics import compute_artist_metrics, enrich_song_with_metrics, enrich_viral_alert, enrich_songs_bulk, enrich_viral_alerts_bulk
21
+ from cache import ttl_cache, invalidate_cache
22
 
23
  app = FastAPI(title="Music Intelligence Dashboard API", version="1.0.0")
24
 
 
1030
  @app.get("/api/filters")
1031
  def get_filter_options():
1032
  """Get available genre and region options for filter dropdowns."""
1033
+ return _get_filters_cached()
1034
+
1035
+ @ttl_cache(ttl_seconds=300)
1036
+ def _get_filters_cached():
1037
  conn = get_connection()
1038
  genres = [r[0] for r in conn.execute(
1039
  "SELECT DISTINCT genre FROM artists WHERE genre IS NOT NULL ORDER BY genre"
 
1057
  conn.close()
1058
  raise HTTPException(status_code=404, detail="Artist not found")
1059
 
1060
+ # Get songs for this platform — CTEs scoped to this artist's songs only
1061
  songs = conn.execute("""
1062
+ WITH artist_song_ids AS (
1063
+ SELECT id FROM songs WHERE artist_id = ? AND platform = ?
1064
+ ),
1065
+ latest_snaps AS (
1066
+ SELECT ps.song_id, MAX(ps.id) as max_id
1067
+ FROM play_snapshots ps
1068
+ WHERE ps.song_id IN (SELECT id FROM artist_song_ids)
1069
+ GROUP BY ps.song_id
1070
  ),
1071
  first_snaps AS (
1072
+ SELECT ps.song_id, MIN(ps.id) as min_id
1073
+ FROM play_snapshots ps
1074
+ WHERE ps.song_id IN (SELECT id FROM artist_song_ids)
1075
+ GROUP BY ps.song_id
1076
  )
1077
  SELECT
1078
  s.*,
 
1091
  LEFT JOIN play_snapshots fs_data ON fs.min_id = fs_data.id
1092
  WHERE s.artist_id = ? AND s.platform = ?
1093
  ORDER BY latest_play_count DESC NULLS LAST
1094
+ """, (artist_id, platform, artist_id, platform)).fetchall()
1095
 
1096
  # Get viral alerts for this artist
1097
  alerts = conn.execute("""
 
1133
  raise HTTPException(status_code=404, detail="Spotify artist not found")
1134
 
1135
  songs = conn.execute("""
1136
+ WITH artist_song_ids AS (
1137
+ SELECT id FROM songs WHERE artist_id = ? AND platform = 'spotify'
1138
+ ),
1139
+ latest_snaps AS (
1140
+ SELECT ps.song_id, MAX(ps.id) as max_id
1141
+ FROM play_snapshots ps
1142
+ WHERE ps.song_id IN (SELECT id FROM artist_song_ids) AND ps.platform = 'spotify'
1143
+ GROUP BY ps.song_id
1144
  ),
1145
  first_snaps AS (
1146
+ SELECT ps.song_id, MIN(ps.id) as min_id
1147
+ FROM play_snapshots ps
1148
+ WHERE ps.song_id IN (SELECT id FROM artist_song_ids) AND ps.platform = 'spotify'
1149
+ GROUP BY ps.song_id
1150
  )
1151
  SELECT
1152
  s.*,
 
1160
  LEFT JOIN play_snapshots fs_data ON fs.min_id = fs_data.id
1161
  WHERE s.artist_id = ? AND s.platform = 'spotify'
1162
  ORDER BY latest_play_count DESC NULLS LAST
1163
+ """, (artist_id, artist_id)).fetchall()
1164
 
1165
  alerts = conn.execute("""
1166
  SELECT
 
1210
  conn.close()
1211
  raise HTTPException(status_code=404, detail="Artist not found")
1212
 
1213
+ new_status = 0 if int(artist["is_watched"] or 0) else 1
1214
  conn.execute("UPDATE artists SET is_watched = ? WHERE id = ?", (new_status, artist_id))
1215
  conn.commit()
1216
  conn.close()
1217
+ invalidate_cache()
1218
  return {"artist_id": artist_id, "is_watched": bool(new_status)}
1219
 
1220
 
 
1250
  conn.execute("DELETE FROM artists WHERE id = ?", (artist_id,))
1251
  conn.commit()
1252
  conn.close()
1253
+ invalidate_cache()
1254
 
1255
  return {
1256
  "status": "deleted",
 
1264
  @app.get("/api/stats")
1265
  def get_stats():
1266
  """Get overall dashboard statistics."""
1267
+ return _get_stats_cached()
1268
+
1269
+ @ttl_cache(ttl_seconds=60)
1270
+ def _get_stats_cached():
1271
  conn = get_connection()
1272
  counts = conn.execute("""
1273
  SELECT
collector/db.py CHANGED
@@ -2,6 +2,8 @@
2
 
3
  import sqlite3
4
  import os
 
 
5
  from datetime import datetime, timezone, timedelta
6
  from pathlib import Path
7
  from dotenv import load_dotenv
@@ -64,29 +66,50 @@ class LibsqlDictConnection:
64
  def executescript(self, sql):
65
  self.conn.executescript(sql)
66
 
 
 
 
67
  def commit(self):
68
  self.conn.commit()
69
 
70
  def close(self):
71
- # Do not close the connection so it can be pooled globally across requests
72
  pass
73
 
74
  _TURSO_GLOBAL_CONN = None
 
 
75
 
76
  def get_connection():
77
- """Get a SQLite connection with WAL mode for better concurrency."""
78
- global _TURSO_GLOBAL_CONN
 
 
 
 
79
  if _USE_TURSO:
80
  if libsql is None:
81
  raise ImportError(
82
  "libsql_experimental is required for Turso connections. "
83
  "Install it with: pip install libsql-experimental"
84
  )
85
- if _TURSO_GLOBAL_CONN is None:
86
- safe_url = _TURSO_URL.replace("libsql://", "https://").replace("wss://", "https://")
87
- conn = libsql.connect(safe_url, auth_token=_TURSO_TOKEN)
88
- _TURSO_GLOBAL_CONN = LibsqlDictConnection(conn)
89
- return _TURSO_GLOBAL_CONN
 
 
 
 
 
 
 
 
 
 
 
 
90
  else:
91
  # Local SQLite fallback for development
92
  conn = sqlite3.connect(str(DB_PATH))
@@ -124,9 +147,13 @@ def init_db():
124
  else:
125
  print(f"Connected to Turso at {_TURSO_URL}")
126
 
 
127
 
128
  def ensure_indexes():
129
- """Create performance indexes if they don't exist."""
 
 
 
130
  conn = get_connection()
131
  indexes = [
132
  "CREATE INDEX IF NOT EXISTS idx_snapshots_song_platform_date ON play_snapshots(song_id, platform, collected_at DESC)",
@@ -142,9 +169,10 @@ def ensure_indexes():
142
  conn.commit()
143
  except Exception as e:
144
  print(f"[indexes] Skipping: {e}")
 
145
  print("[indexes] Performance indexes ensured.")
146
 
147
- # Auto-create indexes on import
148
  try:
149
  ensure_indexes()
150
  except Exception:
 
2
 
3
  import sqlite3
4
  import os
5
+ import time
6
+ import threading
7
  from datetime import datetime, timezone, timedelta
8
  from pathlib import Path
9
  from dotenv import load_dotenv
 
66
  def executescript(self, sql):
67
  self.conn.executescript(sql)
68
 
69
+ def executemany(self, sql, params_list):
70
+ self.conn.executemany(sql, params_list)
71
+
72
  def commit(self):
73
  self.conn.commit()
74
 
75
  def close(self):
76
+ # No-op for pooled Turso connection — connection is reused globally
77
  pass
78
 
79
  _TURSO_GLOBAL_CONN = None
80
+ _TURSO_LAST_OK = 0 # timestamp of last successful use
81
+ _TURSO_LOCK = threading.Lock()
82
 
83
  def get_connection():
84
+ """Get a SQLite connection with WAL mode for better concurrency.
85
+
86
+ For Turso: uses a global pooled connection with auto-reconnect.
87
+ Health-checks the connection if idle for >30 seconds.
88
+ """
89
+ global _TURSO_GLOBAL_CONN, _TURSO_LAST_OK
90
  if _USE_TURSO:
91
  if libsql is None:
92
  raise ImportError(
93
  "libsql_experimental is required for Turso connections. "
94
  "Install it with: pip install libsql-experimental"
95
  )
96
+ with _TURSO_LOCK:
97
+ now = time.time()
98
+ # Health check only if connection has been idle for >30 seconds
99
+ if _TURSO_GLOBAL_CONN is not None and (now - _TURSO_LAST_OK) > 30:
100
+ try:
101
+ _TURSO_GLOBAL_CONN.execute("SELECT 1").fetchone()
102
+ except Exception:
103
+ print("[db] Turso connection stale, reconnecting...")
104
+ _TURSO_GLOBAL_CONN = None
105
+ # Create new connection if needed
106
+ if _TURSO_GLOBAL_CONN is None:
107
+ safe_url = _TURSO_URL.replace("libsql://", "https://").replace("wss://", "https://")
108
+ conn = libsql.connect(safe_url, auth_token=_TURSO_TOKEN)
109
+ _TURSO_GLOBAL_CONN = LibsqlDictConnection(conn)
110
+ print("[db] Turso connection established.")
111
+ _TURSO_LAST_OK = now
112
+ return _TURSO_GLOBAL_CONN
113
  else:
114
  # Local SQLite fallback for development
115
  conn = sqlite3.connect(str(DB_PATH))
 
147
  else:
148
  print(f"Connected to Turso at {_TURSO_URL}")
149
 
150
+ _INDEXES_CREATED = False
151
 
152
  def ensure_indexes():
153
+ """Create performance indexes if they don't exist. Runs once per process."""
154
+ global _INDEXES_CREATED
155
+ if _INDEXES_CREATED:
156
+ return
157
  conn = get_connection()
158
  indexes = [
159
  "CREATE INDEX IF NOT EXISTS idx_snapshots_song_platform_date ON play_snapshots(song_id, platform, collected_at DESC)",
 
169
  conn.commit()
170
  except Exception as e:
171
  print(f"[indexes] Skipping: {e}")
172
+ _INDEXES_CREATED = True
173
  print("[indexes] Performance indexes ensured.")
174
 
175
+ # Auto-create indexes on first import (runs once)
176
  try:
177
  ensure_indexes()
178
  except Exception:
test_db.py DELETED
@@ -1,10 +0,0 @@
1
- import os, sys
2
- sys.path.insert(0, 'collector')
3
- from dotenv import load_dotenv
4
- import db
5
-
6
- load_dotenv()
7
- conn = db.get_connection()
8
- res = conn.execute("SELECT release_date, title, platform FROM songs ORDER BY release_date DESC LIMIT 5").fetchall()
9
- for r in res:
10
- print(r)
 
 
 
 
 
 
 
 
 
 
 
test_perf.py DELETED
@@ -1,53 +0,0 @@
1
- import os, sys, time
2
- sys.path.insert(0, 'collector')
3
- from dotenv import load_dotenv
4
- import db
5
-
6
- load_dotenv()
7
- conn = db.get_connection()
8
-
9
- q1 = """
10
- SELECT
11
- a.id,
12
- (SELECT COUNT(*) FROM songs WHERE artist_id = a.id AND platform = 'youtube') as yt_song_count,
13
- (SELECT SUM(ps.play_count) FROM play_snapshots ps
14
- JOIN songs s ON ps.song_id = s.id
15
- WHERE s.artist_id = a.id AND ps.platform = 'youtube'
16
- AND ps.id IN (SELECT MAX(id) FROM play_snapshots GROUP BY song_id)
17
- ) as total_yt_views
18
- FROM artists a
19
- LIMIT 5
20
- """
21
-
22
- q2 = """
23
- WITH latest_snapshots AS (
24
- SELECT song_id, MAX(id) as max_id
25
- FROM play_snapshots
26
- GROUP BY song_id
27
- ),
28
- song_stats AS (
29
- SELECT
30
- s.artist_id,
31
- COUNT(CASE WHEN s.platform = 'youtube' THEN 1 END) as yt_song_count,
32
- SUM(CASE WHEN s.platform = 'youtube' THEN ps.play_count ELSE 0 END) as total_yt_views
33
- FROM songs s
34
- LEFT JOIN latest_snapshots ls ON s.id = ls.song_id
35
- LEFT JOIN play_snapshots ps ON ls.max_id = ps.id
36
- GROUP BY s.artist_id
37
- )
38
- SELECT
39
- a.id,
40
- COALESCE(ss.yt_song_count, 0) as yt_song_count,
41
- COALESCE(ss.total_yt_views, 0) as total_yt_views
42
- FROM artists a
43
- LEFT JOIN song_stats ss ON a.id = ss.artist_id
44
- LIMIT 5
45
- """
46
-
47
- start = time.time()
48
- conn.execute(q1).fetchall()
49
- print("Q1 (Old):", time.time() - start)
50
-
51
- start = time.time()
52
- conn.execute(q2).fetchall()
53
- print("Q2 (New):", time.time() - start)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
test_perf_artist.py DELETED
@@ -1,65 +0,0 @@
1
- import os, sys, time
2
- sys.path.insert(0, 'collector')
3
- from dotenv import load_dotenv
4
- import db
5
-
6
- load_dotenv()
7
- conn = db.get_connection()
8
-
9
- artist_id = "arooh"
10
- platform = "youtube"
11
-
12
- q1 = """
13
- SELECT
14
- s.*,
15
- (SELECT play_count FROM play_snapshots
16
- WHERE song_id = s.id ORDER BY collected_at DESC LIMIT 1) as latest_play_count,
17
- (SELECT play_count FROM play_snapshots
18
- WHERE song_id = s.id ORDER BY collected_at ASC LIMIT 1) as first_play_count,
19
- (SELECT like_count FROM play_snapshots
20
- WHERE song_id = s.id ORDER BY collected_at DESC LIMIT 1) as latest_like_count,
21
- (SELECT comment_count FROM play_snapshots
22
- WHERE song_id = s.id ORDER BY collected_at DESC LIMIT 1) as latest_comment_count,
23
- (SELECT ytmusic_play_count FROM play_snapshots
24
- WHERE song_id = s.id AND ytmusic_play_count IS NOT NULL ORDER BY collected_at DESC LIMIT 1) as ytmusic_play_count
25
- FROM songs s
26
- WHERE s.artist_id = ? AND s.platform = ?
27
- ORDER BY latest_play_count DESC NULLS LAST
28
- """
29
-
30
- q2 = """
31
- WITH latest_snaps AS (
32
- SELECT song_id, MAX(id) as max_id
33
- FROM play_snapshots
34
- WHERE platform = ?
35
- GROUP BY song_id
36
- ),
37
- first_snaps AS (
38
- SELECT song_id, MIN(id) as min_id
39
- FROM play_snapshots
40
- WHERE platform = ?
41
- GROUP BY song_id
42
- )
43
- SELECT
44
- s.*,
45
- ls_data.play_count as latest_play_count,
46
- fs_data.play_count as first_play_count,
47
- ls_data.like_count as latest_like_count,
48
- ls_data.comment_count as latest_comment_count,
49
- ls_data.ytmusic_play_count as ytmusic_play_count
50
- FROM songs s
51
- LEFT JOIN latest_snaps ls ON s.id = ls.song_id
52
- LEFT JOIN play_snapshots ls_data ON ls.max_id = ls_data.id
53
- LEFT JOIN first_snaps fs ON s.id = fs.song_id
54
- LEFT JOIN play_snapshots fs_data ON fs.min_id = fs_data.id
55
- WHERE s.artist_id = ? AND s.platform = ?
56
- ORDER BY latest_play_count DESC NULLS LAST
57
- """
58
-
59
- start = time.time()
60
- conn.execute(q1, (artist_id, platform)).fetchall()
61
- print("Q1 (Old):", time.time() - start)
62
-
63
- start = time.time()
64
- conn.execute(q2, (platform, platform, artist_id, platform)).fetchall()
65
- print("Q2 (New):", time.time() - start)