Coverage for app/backend/src/couchers/jobs/worker.py: 73%

114 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 22:32 +0000

1""" 

2Background job workers 

3""" 

4 

5import logging 

6import threading 

7import traceback 

8from collections.abc import Callable 

9from datetime import timedelta 

10from multiprocessing import Process 

11from sched import scheduler 

12from time import monotonic, perf_counter_ns, sleep 

13from typing import Any 

14 

15import sentry_sdk 

16from google.protobuf import empty_pb2 

17from opentelemetry import trace 

18from sqlalchemy import select 

19 

20from couchers.config import config 

21from couchers.db import db_post_fork, session_scope 

22from couchers.experimentation import setup_experimentation 

23from couchers.i18n.locales import get_main_i18next 

24from couchers.jobs.definitions import JOBS, Job 

25from couchers.jobs.enqueue import queue_job 

26from couchers.metrics import ( 

27 background_jobs_got_job_counter, 

28 background_jobs_no_jobs_counter, 

29 jobs_queued_histogram, 

30 observe_in_jobs_duration_histogram, 

31) 

32from couchers.models import BackgroundJob, BackgroundJobState 

33from couchers.profiling import setup_profiling 

34from couchers.tracing import setup_tracing 

35from couchers.utils import now 

36 

37logger = logging.getLogger(__name__) 

38tracer = trace.get_tracer(__name__) 

39 

40 

41def process_job() -> bool: 

42 """ 

43 Attempt to process one job from the job queue. Returns False if no job was found, True if a job was processed, 

44 regardless of failure/success. 

45 """ 

46 logger.debug("Looking for a job") 

47 

48 with session_scope() as session: 

49 # SELECT ... FOR UPDATE is what makes sure only one worker handles a given job: no two transactions can hold 

50 # the row lock at once. SKIP LOCKED means an already-claimed job is passed over rather than waited on, so 

51 # workers spread out across the queue. This must run at READ COMMITTED (the default): a stricter isolation 

52 # level can't follow the update chain of a row another worker just committed, and aborts the whole dequeue 

53 # with a serialization error instead of skipping the row 

54 job = ( 

55 session.execute( 

56 select(BackgroundJob) 

57 .where(BackgroundJob.ready_for_retry) 

58 .order_by(BackgroundJob.priority.desc(), BackgroundJob.next_attempt_after.asc()) 

59 .limit(1) 

60 .with_for_update(skip_locked=True) 

61 ) 

62 .scalars() 

63 .one_or_none() 

64 ) 

65 

66 if not job: 

67 background_jobs_no_jobs_counter.inc() 

68 logger.debug("No pending jobs") 

69 return False 

70 

71 background_jobs_got_job_counter.inc() 

72 

73 # we've got a lock for a job now, it's "pending" until we commit or the lock is gone 

74 logger.info(f"Job #{job.id} of type {job.job_type} grabbed") 

75 job.try_count += 1 

76 

77 job_def = JOBS[job.job_type] 

78 

79 jobs_queued_histogram.labels(str(job.priority)).observe((now() - job.queued).total_seconds()) 

80 try: 

81 with tracer.start_as_current_span(job.job_type) as rollspan: 

82 start = perf_counter_ns() 

83 job_def.handler(job_def.payload_type.FromString(job.payload)) 

84 finished = perf_counter_ns() 

85 job.state = BackgroundJobState.completed 

86 observe_in_jobs_duration_histogram( 

87 job.job_type, job.state.name, job.try_count, "", (finished - start) / 1e9 

88 ) 

89 logger.info(f"Job #{job.id} complete on try number {job.try_count}") 

90 except Exception as e: 

91 finished = perf_counter_ns() 

92 # not sentry_sdk.set_tag: that writes to the thread's isolation scope, where the tags stick to 

93 # every later report from this thread. logger.exception is in here so its event is tagged too 

94 with sentry_sdk.new_scope() as scope: 

95 scope.set_tag("context", "job") 

96 scope.set_tag("job", job.job_type) 

97 logger.exception(e) 

98 sentry_sdk.capture_exception(e) 

99 

100 if job.try_count >= job.max_tries: 

101 # if we already tried max_tries times, it's permanently failed 

102 job.state = BackgroundJobState.failed 

103 logger.info(f"Job #{job.id} failed on try number {job.try_count}") 

104 else: 

105 job.state = BackgroundJobState.error 

106 # exponential backoff 

107 job.next_attempt_after = now() + timedelta(seconds=15 * (2**job.try_count)) 

108 logger.info(f"Job #{job.id} error on try number {job.try_count}, next try at {job.next_attempt_after}") 

109 observe_in_jobs_duration_histogram( 

110 job.job_type, job.state.name, job.try_count, type(e).__name__, (finished - start) / 1e9 

111 ) 

112 # add some info for debugging 

113 job.failure_info = traceback.format_exc() 

114 

115 if config.IN_TEST: 

116 raise e 

117 

118 # exiting ctx manager commits and releases the row lock 

119 return True 

120 

121 

122def service_jobs() -> None: 

123 """ 

124 Service jobs in an infinite loop 

125 """ 

126 while True: 

127 # if no job was found, sleep for a second, otherwise query for another job straight away 

128 if not process_job(): 

129 sleep(1) 

130 

131 

132def _run_job_and_schedule(sched: scheduler, job_def: Job[Any], frequency: timedelta) -> None: 

133 logger.info(f"Processing job of type {job_def.name}") 

134 

135 # wake ourselves up after frequency 

136 sched.enter( 

137 delay=frequency.total_seconds(), 

138 priority=1, 

139 action=_run_job_and_schedule, 

140 argument=( 

141 sched, 

142 job_def, 

143 frequency, 

144 ), 

145 ) 

146 

147 # queue the job 

148 with session_scope() as session: 

149 queue_job(session, job=job_def.handler, payload=empty_pb2.Empty()) 

150 

151 

152def run_scheduler() -> None: 

153 """ 

154 Schedules jobs according to schedule in JOBS 

155 """ 

156 sched = scheduler(monotonic, sleep) 

157 

158 for job_type, job_def in JOBS.items(): 

159 if job_def.schedule is not None: 159 ↛ 158line 159 didn't jump to line 158 because the condition on line 159 was always true

160 sched.enter( 

161 delay=0, 

162 priority=1, 

163 action=_run_job_and_schedule, 

164 argument=( 

165 sched, 

166 job_def, 

167 job_def.schedule, 

168 ), 

169 ) 

170 

171 sched.run() 

172 

173 

174def _per_process_init(profile_instance: str | None) -> None: 

175 # Post-fork initialization: these services use threading/async internals that 

176 # don't survive fork() and must be initialized fresh in each child process. 

177 # Pyroscope in particular can only be initialized once per process. 

178 db_post_fork() 

179 setup_tracing() 

180 setup_experimentation() 

181 if profile_instance is not None: 

182 setup_profiling(role="worker", instance=profile_instance) 

183 

184 

185def _run_forever(func: Callable[[], None]) -> None: 

186 while True: 

187 try: 

188 logger.info("Background worker starting") 

189 func() 

190 except Exception as e: 

191 logger.critical("Unhandled exception in background worker", exc_info=e) 

192 # cool off in case we have some programming error to not hammer the database 

193 sleep(60) 

194 

195 

196def _scheduler_process_entry() -> None: 

197 _per_process_init(None) 

198 _run_forever(run_scheduler) 

199 

200 

201def _worker_process_entry(profile_instance: str, threads_per_process: int) -> None: 

202 _per_process_init(profile_instance) 

203 # the lru_cache doesn't hold a lock across the load, so otherwise every thread parses all the locales 

204 get_main_i18next() 

205 # threads rather than processes: the handlers are I/O-bound, and a process costs ~200 MB 

206 threads = [ 

207 threading.Thread(target=_run_forever, args=(service_jobs,), name=f"jobs-thread-{i}", daemon=True) 

208 for i in range(threads_per_process) 

209 ] 

210 for t in threads: 

211 t.start() 

212 # the supervisor only watches processes, so a thread dying here would silently cut our capacity: exit 

213 # instead and let it restart us 

214 while all(t.is_alive() for t in threads): 

215 sleep(1) 

216 logger.critical("A jobs thread died, exiting so the supervisor restarts us") 

217 

218 

219def start_jobs_scheduler() -> Process: 

220 scheduler = Process(target=_scheduler_process_entry) 

221 scheduler.start() 

222 return scheduler 

223 

224 

225def start_jobs_worker(index: int, threads_per_process: int) -> Process: 

226 worker = Process( 

227 target=_worker_process_entry, 

228 args=(f"worker-{index}", threads_per_process), 

229 ) 

230 worker.start() 

231 return worker