Skip to content

Commit 89e65cf

Browse files
committed
code review fix 2
1 parent 01e404c commit 89e65cf

6 files changed

Lines changed: 102 additions & 12 deletions

File tree

app_helper.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
load_map_projection,
4747
get_task_info_from_db,
4848
get_task_statuses,
49+
main_task_start_lock,
4950
get_tracks_by_ids,
5051
save_track_analysis_and_embedding,
5152
# Used internally by the build_and_store_* projection orchestration below.
@@ -448,6 +449,15 @@ def cancel_job_and_children_recursive(
448449
REVOKED row for the requested `job_id` (so UI sees one canonical cancelled task).
449450
This is intentionally simple and destructive (as requested).
450451
"""
452+
# Serialize against the start paths. Cancel scans RQ, then wipes task_status;
453+
# a starter that had committed its PENDING row but not yet enqueued was invisible
454+
# to the scan AND had its row deleted, so it enqueued afterwards and ran after the
455+
# user pressed Cancel.
456+
with main_task_start_lock():
457+
return _cancel_job_and_children_locked(job_id, reason)
458+
459+
460+
def _cancel_job_and_children_locked(job_id, reason):
451461
cancelled_count = 0
452462

453463
# --- Scan RQ for job ids to cancel ---

app_provider_migration.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,15 @@ def __getattr__(self, name):
9797
_COMPLETED_SESSIONS_KEPT = 10
9898

9999

100-
def _migration_job_in_flight(cur):
100+
def _migration_job_in_flight(cur, keys=('dry_run_task_id', 'source_refresh_task_id',
101+
'exec_task_id')):
101102
# Dry-run and source-refresh write no task row and hold no lock, so their only
102103
# durable trace is the RQ job id parked on the session. Without this a second
103104
# and third session start would prune the session out from under a running dry
104105
# run, and the job would fail or silently update nothing.
105106
cur.execute(
106-
"SELECT state->>'dry_run_task_id', state->>'source_refresh_task_id', "
107-
"state->>'exec_task_id' FROM migration_session"
107+
"SELECT " + ", ".join("state->>'%s'" % k for k in keys)
108+
+ " FROM migration_session" # nosec B608 - keys are module constants
108109
)
109110
job_ids = [j for row in (cur.fetchall() or []) for j in row if j]
110111
if not job_ids:
@@ -1584,6 +1585,20 @@ def execute():
15841585
return jsonify({'error': 'session not found'}), 404
15851586
target_type, status, is_current_session = row[0], row[1], row[2]
15861587

1588+
# Refuse while a dry run or source refresh is still producing a plan: the
1589+
# finalized numbers this request confirmed would be applied against a mapping
1590+
# another worker is in the middle of rewriting.
1591+
with db.cursor() as planning:
1592+
if _migration_job_in_flight(
1593+
planning, keys=('dry_run_task_id', 'source_refresh_task_id')
1594+
):
1595+
return jsonify(
1596+
{
1597+
'error': 'A dry run is still building the plan. Wait for it to '
1598+
'finish, then confirm the numbers again.'
1599+
}
1600+
), 409
1601+
15871602
# A session kept alive by the prune is still reachable by id. Executing an older
15881603
# one would repoint the catalogue using a superseded plan.
15891604
if not is_current_session:

restart_listener.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@
2323

2424
import logging
2525
import os
26+
import socket
2627
import threading
2728
import time
2829

2930
from app_logging import configure_logging
3031
from taskqueue import new_redis_connection
3132
from restart_manager import (
3233
RESTART_CHANNEL,
34+
WORKER_PRESENCE_PREFIX,
3335
restart_supervisor_workers,
3436
stop_supervisor_workers,
3537
start_supervisor_workers,
@@ -38,6 +40,21 @@
3840
logger = logging.getLogger(__name__)
3941
configure_logging()
4042

43+
PRESENCE_REFRESH_SECONDS = 15
44+
45+
PRESENCE_TTL_SECONDS = 60
46+
47+
48+
def presence_key():
49+
return f"{WORKER_PRESENCE_PREFIX}{socket.gethostname()}:{os.getpid()}"
50+
51+
52+
def announce_presence(redis_conn):
53+
try:
54+
redis_conn.set(presence_key(), '1', ex=PRESENCE_TTL_SECONDS)
55+
except Exception:
56+
logger.exception('Could not refresh the worker restart-listener presence key')
57+
4158
try:
4259
from plugin.manager import worker_presync
4360
except Exception:
@@ -76,14 +93,22 @@ def main():
7693
pubsub.subscribe(channel)
7794
logger.info('Subscribed to restart channel. Waiting for restart messages...')
7895

79-
for message in pubsub.listen():
96+
# get_message with a timeout instead of listen(): the presence key below
97+
# has to be refreshed on a schedule, and listen() blocks forever between
98+
# messages. Only a WORKER-role listener registers, because only it acts
99+
# on a signal - the Flask container subscribes and ignores, which is why
100+
# counting PUBLISH subscribers could never prove delivery.
101+
while True:
102+
service_type = os.environ.get('SERVICE_TYPE', '').lower()
103+
if service_type == 'worker':
104+
announce_presence(redis_conn)
105+
message = pubsub.get_message(timeout=PRESENCE_REFRESH_SECONDS)
80106
if not message:
81107
continue
82108
if message.get('type') != 'message':
83109
continue
84110
payload = message.get('data')
85111
logger.info('Control listener received signal: %s', payload)
86-
service_type = os.environ.get('SERVICE_TYPE', '').lower()
87112
if service_type != 'worker':
88113
logger.info('Control signal received, but SERVICE_TYPE is not worker; skipping')
89114
continue

restart_manager.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
SUPERVISOR_CONF = os.environ.get('SUPERVISOR_CONF', '/etc/supervisor/conf.d/supervisord.conf')
3434
logger = logging.getLogger(__name__)
3535

36+
WORKER_PRESENCE_PREFIX = 'audiomuse:worker_restart_listener:'
37+
3638
FLASK_SERVICE = ['flask']
3739
WORKER_SERVICES = ['rq-worker-default', 'rq-worker-high', 'rq-janitor']
3840

@@ -44,14 +46,24 @@ def publish_control_request(action):
4446
socket_timeout=5,
4547
decode_responses=True,
4648
)
47-
delivered = redis_conn.publish(RESTART_CHANNEL, action)
48-
if not delivered:
49+
# Subscriber COUNT proves nothing: the Flask container also subscribes and
50+
# then ignores every signal, so publish() returns >= 1 even when the worker
51+
# listener - the only one that acts - is dead. Worker listeners announce
52+
# themselves with a TTL key instead, so absence is a real answer.
53+
listeners = 0
54+
try:
55+
for _ in redis_conn.scan_iter(match=f'{WORKER_PRESENCE_PREFIX}*', count=100):
56+
listeners += 1
57+
break
58+
except Exception:
59+
logger.exception('Could not check for live worker restart listeners')
60+
listeners = -1
61+
redis_conn.publish(RESTART_CHANNEL, action)
62+
if listeners == 0:
4963
logger.error(
50-
'PUBLISHED %s TO REDIS BUT NO LISTENER RECEIVED IT. Every deployment '
51-
'runs a restart-listener (supervisord config-restart-listener, or the '
52-
'native supervisor role), so zero subscribers means it is not running '
53-
'and the workers are STILL ON THE OLD CONFIGURATION. Restart the '
54-
'container or the app.',
64+
'PUBLISHED %s BUT NO WORKER RESTART-LISTENER IS ALIVE. The workers are '
65+
'STILL ON THE OLD CONFIGURATION. Check that the worker container (or '
66+
'the native worker role) is running its restart listener.',
5567
action,
5668
)
5769
return False

tasks/multiserver_sync.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,10 @@ def recover_abandoned_sweeps():
257257
"a fresh alignment of all servers was enqueued."
258258
)
259259
details = json.dumps({'message': message, 'status_message': message})
260+
# One transaction: revoking the abandoned sweep and inserting its
261+
# replacement used to be two autocommits, so a crash in between retired the
262+
# alignment and left nothing to take its place.
263+
db.autocommit = False
260264
cur = db.cursor()
261265
try:
262266
cur.execute(
@@ -270,15 +274,21 @@ def recover_abandoned_sweeps():
270274
)
271275
revoked_count = cur.rowcount
272276
if not revoked_count:
277+
db.rollback()
273278
return None
274279
new_task_id = str(uuid.uuid4())
275280
insert_pending_sweep_row(
276281
cur, new_task_id,
277282
'Server alignment queued for all servers.',
278283
full_refresh=full_refresh,
279284
)
285+
db.commit()
286+
except Exception:
287+
db.rollback()
288+
raise
280289
finally:
281290
cur.close()
291+
db.autocommit = True
282292
rq_queue_high.enqueue(
283293
'tasks.multiserver_sync.sweep_all_secondary_servers',
284294
kwargs={'task_id': new_task_id, 'full_refresh': full_refresh},
@@ -299,6 +309,8 @@ def recover_abandoned_sweeps():
299309
logger.debug("Recovery connection close failed", exc_info=True)
300310

301311

312+
_JANITOR_LOCK_KEY = 6193044728150337
313+
302314
_ORPHAN_GRACE_SECONDS = 120
303315

304316
_INLINE_STALE_SECONDS = 1800
@@ -366,6 +378,14 @@ def reap_orphaned_tasks():
366378

367379
db = connect_raw()
368380
db.autocommit = True
381+
# One janitor at a time. Two of them probing the same abandoned job both
382+
# decided to requeue it, because retrying had no claim anywhere. The lock is
383+
# session scoped, so closing this connection releases it.
384+
with db.cursor() as claim:
385+
claim.execute("SELECT pg_try_advisory_lock(%s)", (_JANITOR_LOCK_KEY,))
386+
if not claim.fetchone()[0]:
387+
db.close()
388+
return 0
369389
missing = []
370390
terminal = {}
371391
restarted = []

test/unit/test_app_cancel.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,19 @@
2222
* A failed status QUERY is not an empty answer, and leaves the task running
2323
"""
2424

25+
from contextlib import nullcontext
2526
from unittest.mock import MagicMock, patch
2627

2728
import pytest
2829

2930

31+
@pytest.fixture(autouse=True)
32+
def stub_the_start_lock(monkeypatch):
33+
import app_helper
34+
35+
monkeypatch.setattr(app_helper, 'main_task_start_lock', nullcontext)
36+
37+
3038
class _FakeCursor:
3139
"""Records executed SQL and answers the snapshot SELECT."""
3240

0 commit comments

Comments
 (0)