Skip to content

Commit 2be0ecf

Browse files
committed
Provider migration: survive its own restart, carry fingerprints and the unsignable marker, and stop tie-breaks skewing the dashboard pies
1 parent 9153189 commit 2be0ecf

9 files changed

Lines changed: 527 additions & 59 deletions

app_dashboard.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,7 @@ def _collect_charts_metrics(cur):
597597
# never buffers all rows at once (an unnamed cursor would).
598598
mood_dominant_counts = {}
599599
other_feature_dominant_counts = {}
600+
tied_songs = 0
600601
complete = True
601602
try:
602603
with cur.connection.cursor(name='dash_mood_scan') as scan:
@@ -611,26 +612,35 @@ def _collect_charts_metrics(cur):
611612
parsed = _parse_keyval(mv)
612613
if not parsed:
613614
continue
614-
dom = max(parsed.items(), key=lambda kv: kv[1])[0]
615-
mood_dominant_counts[dom] = mood_dominant_counts.get(dom, 0) + 1
615+
dom = _dominant_label(parsed)
616+
if dom:
617+
mood_dominant_counts[dom[0]] = mood_dominant_counts.get(dom[0], 0) + 1
618+
else:
619+
tied_songs += 1
616620

617621
if of:
618622
emotional_scores = {
619623
k: s for k, s in _parse_keyval(of).items()
620624
if k not in ('tempo_normalized', 'energy_normalized')
621625
}
622-
top = max(
623-
emotional_scores.items(), key=lambda kv: kv[1], default=None
624-
)
626+
top = _dominant_label(emotional_scores)
625627
# analyze_track writes ZERO_OTHER_FEATURES up front and only
626628
# refresh_other_features fills it in once CLAP lands, so an
627629
# all-zero row means "not scored yet", not "the least
628630
# danceable song in the library". Counting its argmax would
629631
# hand every un-CLAPped song to whichever label parses first.
630-
if top and top[1] > 0:
632+
if top is None:
633+
tied_songs += 1
634+
elif top[1] > 0:
631635
other_feature_dominant_counts[top[0]] = (
632636
other_feature_dominant_counts.get(top[0], 0) + 1
633637
)
638+
if tied_songs:
639+
logger.info(
640+
"dashboard: %d song(s) had no single strongest label and were left "
641+
"out of the dominance counts rather than handed to whichever label "
642+
"parses first", tied_songs,
643+
)
634644
except Exception as e:
635645
logger.debug(f"dashboard: mood aggregation failed: {e}")
636646
_safe_rollback(cur)
@@ -687,6 +697,16 @@ def _collect_charts_metrics(cur):
687697
return metrics
688698

689699

700+
def _dominant_label(scores):
701+
if not scores:
702+
return None
703+
best = max(scores.values())
704+
winners = [label for label, score in scores.items() if score == best]
705+
if len(winners) != 1:
706+
return None
707+
return winners[0], best
708+
709+
690710
def _parse_keyval(s):
691711
"""Parse a ``key:value,key:value`` string (as stored in the ``score``
692712
table's ``mood_vector`` / ``other_features`` columns) into a dict of

app_provider_migration.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,20 @@ def _current_provider_creds():
184184
return None, {}
185185

186186

187+
def _overrides_by_catalogue_id(by_provider_id):
188+
if not by_provider_id:
189+
return {}
190+
from tasks.mediaserver import registry
191+
192+
canonical_of = registry.canonical_input_ids(list(by_provider_id.keys()))
193+
overrides = {}
194+
for provider_id in sorted(by_provider_id):
195+
catalogue_id = canonical_of.get(provider_id, provider_id)
196+
if catalogue_id not in overrides:
197+
overrides[catalogue_id] = by_provider_id[provider_id]
198+
return overrides
199+
200+
187201
def _apply_source_path_overrides(old_rows, overrides):
188202
"""Patch ``old_rows[i]['file_path']`` from the overrides dict in place.
189203
@@ -831,7 +845,9 @@ def run_source_refresh_core(session_id):
831845
tracks = provider_probe.fetch_all_tracks(source_type, creds)
832846

833847
path_format = _detect_path_format(tracks)
834-
overrides = {t['id']: t['path'] for t in tracks if t.get('id') and t.get('path')}
848+
overrides = _overrides_by_catalogue_id(
849+
{t['id']: t['path'] for t in tracks if t.get('id') and t.get('path')}
850+
)
835851

836852
warnings = []
837853
if path_format != 'absolute':

tasks/fingerprint_canonicalize.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,15 @@
109109
# 'analysis' match tier. Such a row can never be relabelled, so counting it as
110110
# legacy work made this "one-time" migration re-hash the whole catalogue on EVERY
111111
# boot and relabel nothing.
112+
# The fp_0 head IS the marker for a row minted as unsignable, and unlike the map
113+
# row it cannot be taken away: a provider migration unbinds an unmatched song from
114+
# its server, which used to strip the only evidence and hand the row straight back
115+
# to this migration as legacy work it can never relabel.
112116
_UNSIGNABLE_SQL = (
113-
"EXISTS (SELECT 1 FROM track_server_map t "
114-
"WHERE t.item_id = s.item_id AND t.match_tier = 'analysis')"
117+
"((s.item_id LIKE 'fp\\_0%%' AND length(s.item_id) = "
118+
+ str(simhash.CANONICAL_ID_LEN)
119+
+ ") OR EXISTS (SELECT 1 FROM track_server_map t "
120+
"WHERE t.item_id = s.item_id AND t.match_tier = 'analysis'))"
115121
)
116122
_LEGACY_ROW_SQL = "NOT " + _CURRENT_SCHEME_SQL + " AND NOT " + _UNSIGNABLE_SQL
117123
_RELABEL_ADVISORY_LOCK = 726354822

tasks/multiserver_sync.py

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ def _sweep_job_state(task_id):
104104

105105
_recovery_state = {'last': None}
106106

107+
# A row is written BEFORE its RQ job is enqueued (and, for a provider migration,
108+
# committed with the migration itself), so a brand-new row legitimately has no job
109+
# yet. Without this, recovery could revoke a perfectly good alignment microseconds
110+
# before its enqueue landed and run a second one alongside it.
111+
_ENQUEUE_GRACE_SECONDS = 120
112+
107113
_ABANDONED_FIRST_SEEN = {}
108114

109115
_ABANDONED_CONFIRM_SECONDS = 60
@@ -135,23 +141,28 @@ def _details_full_refresh(details):
135141
return bool(details.get('full_refresh')) if isinstance(details, dict) else False
136142

137143

138-
def enqueue_server_alignment(server_id=None, message=None):
139-
"""Queue a full-refresh alignment of ONE server (the default when unnamed).
140-
141-
Raw connection and a direct enqueue, like ``recover_abandoned_sweeps``, so a
142-
caller with no Flask app context - the provider migration runs in an RQ job -
143-
can ask for the alignment that rebuilds what a provider swap cannot carry:
144-
the server's artist ids and the file paths of the tracks it repointed.
145-
Returns the task id, or None when there is no server to align.
146-
"""
144+
def insert_pending_sweep_row(cur, task_id, message, full_refresh=True):
147145
import config
146+
147+
details = json.dumps({
148+
'message': message,
149+
'status_message': message,
150+
'full_refresh': bool(full_refresh),
151+
})
152+
cur.execute(
153+
"INSERT INTO task_status "
154+
"(task_id, task_type, status, progress, details, timestamp, start_time) "
155+
"VALUES (%s, %s, %s, 0, %s, NOW(), %s) "
156+
"ON CONFLICT (task_id) DO NOTHING",
157+
(task_id, SWEEP_TASK_TYPE, config.TASK_STATUS_PENDING, details, time.time()),
158+
)
159+
160+
161+
def enqueue_server_alignment(server_id=None, message=None, task_id=None):
148162
from app_helper import rq_queue_high
149163

150-
task_id = str(uuid.uuid4())
164+
task_id = task_id or str(uuid.uuid4())
151165
text = message or 'Server alignment queued.'
152-
details = json.dumps(
153-
{'message': text, 'status_message': text, 'full_refresh': True}
154-
)
155166
db = connect_raw()
156167
db.autocommit = True
157168
try:
@@ -160,13 +171,7 @@ def enqueue_server_alignment(server_id=None, message=None):
160171
return None
161172
cur = db.cursor()
162173
try:
163-
cur.execute(
164-
"INSERT INTO task_status "
165-
"(task_id, task_type, status, progress, details, timestamp, start_time) "
166-
"VALUES (%s, %s, %s, 0, %s, NOW(), %s) "
167-
"ON CONFLICT (task_id) DO NOTHING",
168-
(task_id, SWEEP_TASK_TYPE, config.TASK_STATUS_PENDING, details, time.time()),
169-
)
174+
insert_pending_sweep_row(cur, task_id, text)
170175
finally:
171176
cur.close()
172177
finally:
@@ -220,9 +225,11 @@ def recover_abandoned_sweeps():
220225
try:
221226
cur.execute(
222227
"SELECT task_id, details FROM task_status WHERE task_type = %s "
223-
"AND status NOT IN (%s, %s, %s)",
228+
"AND status NOT IN (%s, %s, %s) "
229+
"AND timestamp < NOW() - make_interval(secs => %s)",
224230
(SWEEP_TASK_TYPE, config.TASK_STATUS_SUCCESS,
225-
config.TASK_STATUS_FAILURE, config.TASK_STATUS_REVOKED),
231+
config.TASK_STATUS_FAILURE, config.TASK_STATUS_REVOKED,
232+
_ENQUEUE_GRACE_SECONDS),
226233
)
227234
rows = cur.fetchall()
228235
candidates = [r[0] for r in rows]
@@ -264,16 +271,10 @@ def recover_abandoned_sweeps():
264271
if not revoked_count:
265272
return None
266273
new_task_id = str(uuid.uuid4())
267-
queued = json.dumps({
268-
'message': 'Server alignment queued for all servers.',
269-
'full_refresh': full_refresh,
270-
})
271-
cur.execute(
272-
"INSERT INTO task_status "
273-
"(task_id, task_type, status, progress, details, timestamp, start_time) "
274-
"VALUES (%s, %s, %s, 0, %s, NOW(), %s) "
275-
"ON CONFLICT (task_id) DO NOTHING",
276-
(new_task_id, SWEEP_TASK_TYPE, config.TASK_STATUS_PENDING, queued, now),
274+
insert_pending_sweep_row(
275+
cur, new_task_id,
276+
'Server alignment queued for all servers.',
277+
full_refresh=full_refresh,
277278
)
278279
finally:
279280
cur.close()

0 commit comments

Comments
 (0)