dsw-command-queue 4.32.0__tar.gz → 4.34.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dsw_command_queue-4.32.0 → dsw_command_queue-4.34.0}/PKG-INFO +4 -6
- dsw_command_queue-4.34.0/dsw/command_queue/__init__.py +11 -0
- {dsw_command_queue-4.32.0 → dsw_command_queue-4.34.0}/dsw/command_queue/build_info.py +4 -4
- dsw_command_queue-4.34.0/dsw/command_queue/command_queue.py +484 -0
- {dsw_command_queue-4.32.0 → dsw_command_queue-4.34.0}/dsw/command_queue/query.py +34 -5
- dsw_command_queue-4.34.0/pyproject.toml +49 -0
- dsw_command_queue-4.32.0/pyproject.toml → dsw_command_queue-4.34.0/pyproject.toml.orig +5 -7
- dsw_command_queue-4.32.0/dsw/command_queue/__init__.py +0 -4
- dsw_command_queue-4.32.0/dsw/command_queue/command_queue.py +0 -273
- {dsw_command_queue-4.32.0 → dsw_command_queue-4.34.0}/README.md +0 -0
- {dsw_command_queue-4.32.0 → dsw_command_queue-4.34.0}/dsw/command_queue/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: dsw-command-queue
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.34.0
|
|
4
4
|
Summary: Library for working with command queue and persistent commands
|
|
5
5
|
Keywords: dsw,subscriber,publisher,database,queue,processing
|
|
6
6
|
Author: Marek Suchánek
|
|
@@ -9,16 +9,14 @@ License: Apache License 2.0
|
|
|
9
9
|
Classifier: Development Status :: 5 - Production/Stable
|
|
10
10
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
11
|
Classifier: Programming Language :: Python
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
14
12
|
Classifier: Programming Language :: Python :: 3.14
|
|
15
13
|
Classifier: Topic :: Database
|
|
16
14
|
Classifier: Topic :: Text Processing
|
|
17
15
|
Classifier: Topic :: Utilities
|
|
18
|
-
Requires-Dist:
|
|
16
|
+
Requires-Dist: psycopg[binary]>=3.2
|
|
19
17
|
Requires-Dist: tenacity
|
|
20
|
-
Requires-Dist: dsw-database==4.
|
|
21
|
-
Requires-Python: >=3.
|
|
18
|
+
Requires-Dist: dsw-database==4.34.0
|
|
19
|
+
Requires-Python: >=3.14, <4
|
|
22
20
|
Project-URL: Homepage, https://ds-wizard.org
|
|
23
21
|
Project-URL: Repository, https://github.com/ds-wizard/engine-tools
|
|
24
22
|
Project-URL: Documentation, https://guide.ds-wizard.org
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .command_queue import (
|
|
2
|
+
CommandJobError,
|
|
3
|
+
CommandJobTimeoutError,
|
|
4
|
+
CommandQueue,
|
|
5
|
+
CommandWorker,
|
|
6
|
+
FatalCommandQueueError,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = ['CommandJobError', 'CommandJobTimeoutError', 'CommandQueue',
|
|
11
|
+
'CommandWorker', 'FatalCommandQueueError']
|
|
@@ -9,9 +9,9 @@ BuildInfo = namedtuple(
|
|
|
9
9
|
)
|
|
10
10
|
|
|
11
11
|
BUILD_INFO = BuildInfo(
|
|
12
|
-
version='v4.
|
|
13
|
-
built_at='2026-
|
|
14
|
-
sha='
|
|
12
|
+
version='v4.34.0~a59a99f',
|
|
13
|
+
built_at='2026-09-01 09:23:22Z',
|
|
14
|
+
sha='a59a99ff0d9010ceefa9ee899d3b2a3d867966aa',
|
|
15
15
|
branch='HEAD',
|
|
16
|
-
tag='v4.
|
|
16
|
+
tag='v4.34.0',
|
|
17
17
|
)
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import datetime
|
|
5
|
+
import enum
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import select
|
|
9
|
+
import signal
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import typing
|
|
13
|
+
|
|
14
|
+
import psycopg
|
|
15
|
+
import psycopg.errors
|
|
16
|
+
import tenacity
|
|
17
|
+
|
|
18
|
+
from dsw.database.model import PersistentCommand
|
|
19
|
+
|
|
20
|
+
from .query import CommandQueries
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if typing.TYPE_CHECKING:
|
|
24
|
+
from dsw.database import Database
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
LOG = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
RETRY_QUERY_MULTIPLIER = 0.5
|
|
30
|
+
RETRY_QUERY_TRIES = 3
|
|
31
|
+
RETRY_QUEUE_MULTIPLIER = 0.5
|
|
32
|
+
RETRY_QUEUE_TRIES = 5
|
|
33
|
+
|
|
34
|
+
# Pause after a failed command, so a queue full of failing commands
|
|
35
|
+
# cannot be processed in a tight loop
|
|
36
|
+
FAILED_COMMAND_DELAY = 0.5
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _setup_wakeup_pipe() -> tuple[int, int] | tuple[None, None]:
|
|
40
|
+
# Self-pipe makes select() return immediately when a signal is received
|
|
41
|
+
# (works with any POSIX platform, on Windows select() accepts sockets only)
|
|
42
|
+
if os.name != 'posix':
|
|
43
|
+
return None, None
|
|
44
|
+
read_fd, write_fd = os.pipe()
|
|
45
|
+
try:
|
|
46
|
+
os.set_blocking(read_fd, False)
|
|
47
|
+
os.set_blocking(write_fd, False)
|
|
48
|
+
signal.set_wakeup_fd(write_fd)
|
|
49
|
+
except (OSError, ValueError):
|
|
50
|
+
# not in the main thread of the main interpreter
|
|
51
|
+
LOG.warning('Could not set up the signal wakeup pipe', exc_info=True)
|
|
52
|
+
os.close(read_fd)
|
|
53
|
+
os.close(write_fd)
|
|
54
|
+
return None, None
|
|
55
|
+
return read_fd, write_fd
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_WAKEUP_PIPE_R, _WAKEUP_PIPE_W = _setup_wakeup_pipe()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _drain_wakeup_pipe():
|
|
62
|
+
if _WAKEUP_PIPE_R is None:
|
|
63
|
+
return
|
|
64
|
+
try:
|
|
65
|
+
while os.read(_WAKEUP_PIPE_R, 4096):
|
|
66
|
+
pass
|
|
67
|
+
except BlockingIOError:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ProcessResult(enum.Enum):
|
|
72
|
+
DONE = 'done'
|
|
73
|
+
FAILED = 'failed'
|
|
74
|
+
SKIPPED = 'skipped' # taken by someone else, nothing was processed
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class CommandJobError(Exception):
|
|
78
|
+
|
|
79
|
+
def __init__(self, job_id: str, message: str, try_again: bool,
|
|
80
|
+
exc: BaseException | None = None):
|
|
81
|
+
self.job_id = job_id
|
|
82
|
+
self.message = message
|
|
83
|
+
self.try_again = try_again
|
|
84
|
+
self.exc = exc
|
|
85
|
+
super().__init__(message)
|
|
86
|
+
|
|
87
|
+
def __str__(self):
|
|
88
|
+
return self.message
|
|
89
|
+
|
|
90
|
+
def log_message(self):
|
|
91
|
+
if self.exc is None:
|
|
92
|
+
return self.message
|
|
93
|
+
return f'{self.message} (caused by: [{type(self.exc).__name__}] {str(self.exc)})'
|
|
94
|
+
|
|
95
|
+
def db_message(self):
|
|
96
|
+
if self.exc is None:
|
|
97
|
+
return self.message
|
|
98
|
+
return f'{self.message}\n\n' \
|
|
99
|
+
f'Caused by: {type(self.exc).__name__}\n' \
|
|
100
|
+
f'{str(self.exc)}'
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def create(job_id: str, message: str, try_again: bool = True,
|
|
104
|
+
exc: BaseException | None = None):
|
|
105
|
+
if isinstance(exc, CommandJobError):
|
|
106
|
+
return exc
|
|
107
|
+
return CommandJobError(
|
|
108
|
+
job_id=job_id,
|
|
109
|
+
message=message,
|
|
110
|
+
try_again=try_again,
|
|
111
|
+
exc=exc,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class CommandJobTimeoutError(Exception):
|
|
116
|
+
|
|
117
|
+
def __init__(self, timeout: float):
|
|
118
|
+
self.timeout = timeout
|
|
119
|
+
super().__init__(f'Job exceeded the time limit ({timeout} seconds)')
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class FatalCommandQueueError(Exception):
|
|
123
|
+
"""Error that makes it unsafe to continue processing in this process."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class CommandWorker:
|
|
127
|
+
|
|
128
|
+
@abc.abstractmethod
|
|
129
|
+
def work(self, command: PersistentCommand):
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
def process_timeout(self, e: BaseException):
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
def process_exception(self, e: BaseException):
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _CommandJobThread(threading.Thread):
|
|
140
|
+
"""Runs a single job and captures whatever it raises."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, work: typing.Callable[[], None]):
|
|
143
|
+
super().__init__(daemon=True, name='dsw-command-job')
|
|
144
|
+
self.work = work
|
|
145
|
+
self.exception: BaseException | None = None
|
|
146
|
+
|
|
147
|
+
def run(self):
|
|
148
|
+
try:
|
|
149
|
+
self.work()
|
|
150
|
+
except BaseException as e: # noqa: BLE001 (re-raised in the caller thread)
|
|
151
|
+
self.exception = e
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class CommandQueue:
|
|
155
|
+
|
|
156
|
+
def __init__(self, *, worker: CommandWorker, db: Database,
|
|
157
|
+
channel: str, component: str, wait_timeout: float,
|
|
158
|
+
work_timeout: int | None = None):
|
|
159
|
+
self.worker = worker
|
|
160
|
+
self.db = db
|
|
161
|
+
self.queries = CommandQueries(
|
|
162
|
+
channel=channel,
|
|
163
|
+
)
|
|
164
|
+
self.component = component
|
|
165
|
+
self.wait_timeout = wait_timeout
|
|
166
|
+
self.work_timeout = work_timeout
|
|
167
|
+
self._interrupted = False
|
|
168
|
+
|
|
169
|
+
signal.signal(signal.SIGINT, self._signal_handler)
|
|
170
|
+
signal.signal(signal.SIGTERM, self._signal_handler)
|
|
171
|
+
signal.signal(signal.SIGABRT, self._signal_handler)
|
|
172
|
+
|
|
173
|
+
def run(self):
|
|
174
|
+
LOG.info('Starting to process the command queue')
|
|
175
|
+
while not self._interrupted:
|
|
176
|
+
self._run_iteration()
|
|
177
|
+
LOG.info('Exiting command queue')
|
|
178
|
+
|
|
179
|
+
@tenacity.retry(
|
|
180
|
+
reraise=True,
|
|
181
|
+
wait=tenacity.wait_exponential(multiplier=RETRY_QUEUE_MULTIPLIER),
|
|
182
|
+
stop=tenacity.stop_after_attempt(RETRY_QUEUE_TRIES),
|
|
183
|
+
retry=tenacity.retry_if_not_exception_type(FatalCommandQueueError),
|
|
184
|
+
before=tenacity.before_log(LOG, logging.INFO),
|
|
185
|
+
after=tenacity.after_log(LOG, logging.INFO),
|
|
186
|
+
)
|
|
187
|
+
def _run_iteration(self):
|
|
188
|
+
# Retrying is per iteration (not per process), so the budget is spent
|
|
189
|
+
# on a single outage and reset by each successful cycle
|
|
190
|
+
connection = self._ensure_listening()
|
|
191
|
+
self._fetch_and_process_queued()
|
|
192
|
+
if self._interrupted:
|
|
193
|
+
return
|
|
194
|
+
self._wait_for_notifications(connection)
|
|
195
|
+
|
|
196
|
+
@tenacity.retry(
|
|
197
|
+
reraise=True,
|
|
198
|
+
wait=tenacity.wait_exponential(multiplier=RETRY_QUEUE_MULTIPLIER),
|
|
199
|
+
stop=tenacity.stop_after_attempt(RETRY_QUEUE_TRIES),
|
|
200
|
+
retry=tenacity.retry_if_not_exception_type(FatalCommandQueueError),
|
|
201
|
+
before=tenacity.before_log(LOG, logging.INFO),
|
|
202
|
+
after=tenacity.after_log(LOG, logging.INFO),
|
|
203
|
+
)
|
|
204
|
+
def run_once(self):
|
|
205
|
+
LOG.info('Processing the command queue once')
|
|
206
|
+
self._fetch_and_process_queued()
|
|
207
|
+
|
|
208
|
+
def _ensure_listening(self) -> psycopg.Connection:
|
|
209
|
+
# Both the connection and the LISTEN registration are re-checked in
|
|
210
|
+
# each iteration: a re-established connection is not listening anymore
|
|
211
|
+
# and its socket (used for select) is a different one
|
|
212
|
+
queue_conn = self.db.conn_queue
|
|
213
|
+
connection = queue_conn.connection
|
|
214
|
+
if connection.broken:
|
|
215
|
+
LOG.warning('Connection of the command queue is broken, reconnecting')
|
|
216
|
+
queue_conn.reset()
|
|
217
|
+
connection = queue_conn.connection
|
|
218
|
+
if not queue_conn.listening:
|
|
219
|
+
LOG.info('Preparing to listen to command queue (issuing LISTEN)')
|
|
220
|
+
connection.execute(
|
|
221
|
+
query=self.queries.query_listen().encode(),
|
|
222
|
+
)
|
|
223
|
+
queue_conn.listening = True
|
|
224
|
+
LOG.info('Listening to notifications in command queue')
|
|
225
|
+
return connection
|
|
226
|
+
|
|
227
|
+
def _wait_for_notifications(self, connection: psycopg.Connection):
|
|
228
|
+
socket_fd = connection.pgconn.socket
|
|
229
|
+
fds = [socket_fd]
|
|
230
|
+
if _WAKEUP_PIPE_R is not None:
|
|
231
|
+
fds.append(_WAKEUP_PIPE_R)
|
|
232
|
+
|
|
233
|
+
LOG.info('Waiting for notifications (up to %s seconds)', self.wait_timeout)
|
|
234
|
+
readable, _, _ = select.select(fds, [], [], self.wait_timeout)
|
|
235
|
+
|
|
236
|
+
if _WAKEUP_PIPE_R is not None and _WAKEUP_PIPE_R in readable:
|
|
237
|
+
_drain_wakeup_pipe()
|
|
238
|
+
|
|
239
|
+
if self._interrupted:
|
|
240
|
+
LOG.debug('Interrupt signal received, ending...')
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
if len(readable) == 0:
|
|
244
|
+
LOG.info('Nothing received in this cycle (timeout %s seconds)',
|
|
245
|
+
self.wait_timeout)
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
if socket_fd in readable:
|
|
249
|
+
self._receive_notifications(connection)
|
|
250
|
+
|
|
251
|
+
def _receive_notifications(self, connection: psycopg.Connection):
|
|
252
|
+
# A readable socket may also mean that the server closed the connection;
|
|
253
|
+
# reading from it is the only way to find that out (the connection still
|
|
254
|
+
# reports itself as usable until then)
|
|
255
|
+
try:
|
|
256
|
+
connection.pgconn.consume_input()
|
|
257
|
+
notifications = 0
|
|
258
|
+
for notification in connection.notifies(timeout=0):
|
|
259
|
+
notifications += 1
|
|
260
|
+
LOG.info('Notification received: %s', notification)
|
|
261
|
+
LOG.info('Notifications received (%s in total)', notifications)
|
|
262
|
+
except psycopg.Error as e:
|
|
263
|
+
LOG.warning('Connection of the command queue is not usable (%s), '
|
|
264
|
+
'it will be established again', str(e))
|
|
265
|
+
self.db.conn_queue.listening = False
|
|
266
|
+
|
|
267
|
+
def _fetch_and_process_queued(self):
|
|
268
|
+
LOG.info('Fetching the commands')
|
|
269
|
+
count = 0
|
|
270
|
+
while self.fetch_and_process():
|
|
271
|
+
count += 1
|
|
272
|
+
if self._interrupted:
|
|
273
|
+
LOG.debug('Interrupt signal received, stopping the processing')
|
|
274
|
+
break
|
|
275
|
+
LOG.info('There are no more commands to process (%s processed)',
|
|
276
|
+
count)
|
|
277
|
+
|
|
278
|
+
def fetch_and_process(self) -> bool:
|
|
279
|
+
command = self._fetch_command()
|
|
280
|
+
if command is None:
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
LOG.info('Retrieved persistent command %s for processing', command.uuid)
|
|
284
|
+
LOG.info('Previous state: %s', command.state)
|
|
285
|
+
LOG.info('Attempts: %s / %s', command.attempts, command.max_attempts)
|
|
286
|
+
LOG.info('Last error: %s', command.last_error_message)
|
|
287
|
+
|
|
288
|
+
if self._process(command) is ProcessResult.FAILED:
|
|
289
|
+
time.sleep(FAILED_COMMAND_DELAY)
|
|
290
|
+
LOG.info('Notification processing finished')
|
|
291
|
+
return True
|
|
292
|
+
|
|
293
|
+
def _fetch_command(self) -> PersistentCommand | None:
|
|
294
|
+
# SELECT ... FOR UPDATE starts a transaction that must be ended on every
|
|
295
|
+
# exit path, otherwise the connection stays idle in transaction forever
|
|
296
|
+
try:
|
|
297
|
+
with self.db.conn_query.new_cursor(use_dict=True) as cursor:
|
|
298
|
+
cursor.execute(
|
|
299
|
+
query=self.queries.query_get_command(),
|
|
300
|
+
params={
|
|
301
|
+
'component': self.component,
|
|
302
|
+
'now': datetime.datetime.now(tz=datetime.UTC),
|
|
303
|
+
},
|
|
304
|
+
)
|
|
305
|
+
result = cursor.fetchone()
|
|
306
|
+
except Exception:
|
|
307
|
+
self._rollback()
|
|
308
|
+
raise
|
|
309
|
+
if result is None:
|
|
310
|
+
LOG.info('There is no persistent command to process')
|
|
311
|
+
self._rollback()
|
|
312
|
+
return None
|
|
313
|
+
return PersistentCommand.from_dict_row(result)
|
|
314
|
+
|
|
315
|
+
def _process(self, command: PersistentCommand) -> ProcessResult:
|
|
316
|
+
attempt_number = command.attempts + 1
|
|
317
|
+
if not self._start_command(command=command, attempt_number=attempt_number):
|
|
318
|
+
return ProcessResult.SKIPPED
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
self._do_work(command)
|
|
322
|
+
self.db.execute_query(
|
|
323
|
+
query=self.queries.query_command_done(),
|
|
324
|
+
attempts=attempt_number,
|
|
325
|
+
updated_at=datetime.datetime.now(tz=datetime.UTC),
|
|
326
|
+
uuid=command.uuid,
|
|
327
|
+
)
|
|
328
|
+
self._commit()
|
|
329
|
+
return ProcessResult.DONE
|
|
330
|
+
except CommandJobTimeoutError as e:
|
|
331
|
+
msg = f'Processing exceeded time limit ({self.work_timeout} seconds)'
|
|
332
|
+
LOG.error(msg)
|
|
333
|
+
try:
|
|
334
|
+
# The job goes on in its thread and it may be in the middle of
|
|
335
|
+
# using the connection, so this one must not be touched anymore
|
|
336
|
+
self._abandon_connection()
|
|
337
|
+
self.worker.process_timeout(e)
|
|
338
|
+
self._store_result(
|
|
339
|
+
query=self.queries.query_command_error(),
|
|
340
|
+
message=msg,
|
|
341
|
+
attempts=attempt_number,
|
|
342
|
+
uuid=command.uuid,
|
|
343
|
+
)
|
|
344
|
+
except Exception:
|
|
345
|
+
LOG.warning('Failed to store the result of the timed-out command',
|
|
346
|
+
exc_info=True)
|
|
347
|
+
# The job cannot be stopped and it shares the resources of this
|
|
348
|
+
# process (DB connection, S3 client, ...), so it must not continue
|
|
349
|
+
raise FatalCommandQueueError(msg) from e
|
|
350
|
+
except CommandJobError as e:
|
|
351
|
+
if e.try_again and attempt_number < command.max_attempts:
|
|
352
|
+
query = self.queries.query_command_error()
|
|
353
|
+
msg = f'Failed with job error: {e.message} (will try again)'
|
|
354
|
+
else:
|
|
355
|
+
query = self.queries.query_command_error_stop()
|
|
356
|
+
msg = f'Failed with job error: {e.message}'
|
|
357
|
+
LOG.warning(msg)
|
|
358
|
+
self._rollback_work()
|
|
359
|
+
self.worker.process_exception(e)
|
|
360
|
+
self._store_result(
|
|
361
|
+
query=query,
|
|
362
|
+
message=msg,
|
|
363
|
+
attempts=attempt_number,
|
|
364
|
+
uuid=command.uuid,
|
|
365
|
+
)
|
|
366
|
+
except Exception as e:
|
|
367
|
+
if attempt_number < command.max_attempts:
|
|
368
|
+
msg = f'Failed with exception [{type(e).__name__}]: {str(e)} (will try again)'
|
|
369
|
+
else:
|
|
370
|
+
msg = f'Failed with exception [{type(e).__name__}]: {str(e)}'
|
|
371
|
+
LOG.warning(msg)
|
|
372
|
+
self._rollback_work()
|
|
373
|
+
self.worker.process_exception(e)
|
|
374
|
+
self._store_result(
|
|
375
|
+
query=self.queries.query_command_error(),
|
|
376
|
+
message=msg,
|
|
377
|
+
attempts=attempt_number,
|
|
378
|
+
uuid=command.uuid,
|
|
379
|
+
)
|
|
380
|
+
return ProcessResult.FAILED
|
|
381
|
+
|
|
382
|
+
def _abandon_connection(self):
|
|
383
|
+
# Replace the connection that the timed-out job may still be using: any
|
|
384
|
+
# query issued here would wait for its lock (psycopg serializes access
|
|
385
|
+
# to a connection), and that wait could never end
|
|
386
|
+
backend_pid = self.db.conn_query.discard()
|
|
387
|
+
if backend_pid is None:
|
|
388
|
+
return
|
|
389
|
+
# Terminating the backend releases the locks and rolls back whatever the
|
|
390
|
+
# job managed to write, so the result can be stored on the new connection
|
|
391
|
+
LOG.warning('Terminating the backend of the timed-out job (pid: %s)',
|
|
392
|
+
backend_pid)
|
|
393
|
+
self._execute(
|
|
394
|
+
query=self.queries.query_terminate_backend(),
|
|
395
|
+
pid=backend_pid,
|
|
396
|
+
)
|
|
397
|
+
self._commit()
|
|
398
|
+
|
|
399
|
+
def _start_command(self, command: PersistentCommand, attempt_number: int) -> bool:
|
|
400
|
+
# The attempt is stored before the work starts: if the worker dies while
|
|
401
|
+
# processing, the command must not be retried immediately nor forever
|
|
402
|
+
self.db.execute_query(
|
|
403
|
+
query=self.queries.query_command_start(),
|
|
404
|
+
attempts=attempt_number,
|
|
405
|
+
updated_at=datetime.datetime.now(tz=datetime.UTC),
|
|
406
|
+
uuid=command.uuid,
|
|
407
|
+
)
|
|
408
|
+
self._commit()
|
|
409
|
+
# Committing released the lock from SELECT ... FOR UPDATE, it needs to be
|
|
410
|
+
# taken again and held for the whole processing (otherwise another worker
|
|
411
|
+
# could process the very same command concurrently)
|
|
412
|
+
try:
|
|
413
|
+
self._execute(
|
|
414
|
+
query=self.queries.query_lock_command(),
|
|
415
|
+
uuid=command.uuid,
|
|
416
|
+
)
|
|
417
|
+
except psycopg.errors.LockNotAvailable:
|
|
418
|
+
LOG.warning('Command %s is locked by someone else, skipping it',
|
|
419
|
+
command.uuid)
|
|
420
|
+
self._rollback()
|
|
421
|
+
return False
|
|
422
|
+
self._execute(query=self.queries.query_savepoint())
|
|
423
|
+
return True
|
|
424
|
+
|
|
425
|
+
def _do_work(self, command: PersistentCommand):
|
|
426
|
+
def work():
|
|
427
|
+
self.worker.work(command)
|
|
428
|
+
|
|
429
|
+
if self.work_timeout is None:
|
|
430
|
+
LOG.info('Processing (without any timeout set)')
|
|
431
|
+
work()
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
LOG.info('Processing (with timeout set to %s seconds)',
|
|
435
|
+
self.work_timeout)
|
|
436
|
+
thread = _CommandJobThread(work=work)
|
|
437
|
+
thread.start()
|
|
438
|
+
thread.join(timeout=self.work_timeout)
|
|
439
|
+
if thread.is_alive():
|
|
440
|
+
raise CommandJobTimeoutError(timeout=self.work_timeout)
|
|
441
|
+
if thread.exception is not None:
|
|
442
|
+
raise thread.exception
|
|
443
|
+
|
|
444
|
+
def _store_result(self, *, query: str, message: str, attempts: int, uuid: str):
|
|
445
|
+
self.db.execute_query(
|
|
446
|
+
query=query,
|
|
447
|
+
attempts=attempts,
|
|
448
|
+
error_message=message,
|
|
449
|
+
updated_at=datetime.datetime.now(tz=datetime.UTC),
|
|
450
|
+
uuid=uuid,
|
|
451
|
+
)
|
|
452
|
+
self._commit()
|
|
453
|
+
|
|
454
|
+
def _execute(self, query: str, **params):
|
|
455
|
+
with self.db.conn_query.new_cursor() as cursor:
|
|
456
|
+
cursor.execute(query=query, params=params or None)
|
|
457
|
+
|
|
458
|
+
def _commit(self):
|
|
459
|
+
LOG.debug('Committing transaction')
|
|
460
|
+
self.db.conn_query.connection.commit()
|
|
461
|
+
|
|
462
|
+
def _rollback(self):
|
|
463
|
+
LOG.debug('Rolling back transaction')
|
|
464
|
+
try:
|
|
465
|
+
self.db.conn_query.connection.rollback()
|
|
466
|
+
except psycopg.Error:
|
|
467
|
+
LOG.warning('Failed to roll back the transaction', exc_info=True)
|
|
468
|
+
|
|
469
|
+
def _rollback_work(self):
|
|
470
|
+
# Changes made by the failed job must not become durable together with
|
|
471
|
+
# its error record (the job itself may have already ended the
|
|
472
|
+
# transaction, then there is nothing to roll back to)
|
|
473
|
+
try:
|
|
474
|
+
self._execute(query=self.queries.query_rollback_to_savepoint())
|
|
475
|
+
return
|
|
476
|
+
except psycopg.Error as e:
|
|
477
|
+
LOG.info('Could not roll back to savepoint (%s), rolling back fully',
|
|
478
|
+
str(e))
|
|
479
|
+
self._rollback()
|
|
480
|
+
|
|
481
|
+
def _signal_handler(self, recv_signal, frame):
|
|
482
|
+
LOG.warning('Received interrupt signal: %s (frame: %s)',
|
|
483
|
+
recv_signal, frame)
|
|
484
|
+
self._interrupted = True
|
|
@@ -3,6 +3,11 @@ from __future__ import annotations
|
|
|
3
3
|
import enum
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
# Savepoint wrapping the actual work of a command, so partial changes made by
|
|
7
|
+
# a failed job are not committed together with its error record
|
|
8
|
+
JOB_SAVEPOINT = 'dsw_command_job'
|
|
9
|
+
|
|
10
|
+
|
|
6
11
|
class CommandState(enum.Enum):
|
|
7
12
|
NEW = 'NewPersistentCommandState'
|
|
8
13
|
DONE = 'DonePersistentCommandState'
|
|
@@ -18,7 +23,12 @@ class CommandQueries:
|
|
|
18
23
|
def query_listen(self) -> str:
|
|
19
24
|
return f'LISTEN persistent_command_channel__{self.channel};'
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
@staticmethod
|
|
27
|
+
def query_get_command() -> str:
|
|
28
|
+
# The backoff is measured from updated_at (set whenever an attempt
|
|
29
|
+
# starts or finishes), not from created_at: with created_at the
|
|
30
|
+
# exponential term would be just an absolute age threshold that any
|
|
31
|
+
# older command satisfies for all values of attempts at once.
|
|
22
32
|
return """
|
|
23
33
|
SELECT *
|
|
24
34
|
FROM persistent_command
|
|
@@ -26,13 +36,32 @@ class CommandQueries:
|
|
|
26
36
|
AND attempts < max_attempts
|
|
27
37
|
AND state != 'DonePersistentCommandState'
|
|
28
38
|
AND state != 'IgnorePersistentCommandState'
|
|
29
|
-
AND (
|
|
30
|
-
|
|
31
|
-
(%(now)s - (2 ^ attempts - 1) * INTERVAL '1 min')
|
|
32
|
-
ORDER BY attempts ASC, updated_at DESC
|
|
39
|
+
AND updated_at < (%(now)s - (2 ^ attempts - 1) * INTERVAL '1 min')
|
|
40
|
+
ORDER BY attempts ASC, created_at ASC
|
|
33
41
|
LIMIT 1 FOR UPDATE SKIP LOCKED;
|
|
34
42
|
"""
|
|
35
43
|
|
|
44
|
+
@staticmethod
|
|
45
|
+
def query_lock_command() -> str:
|
|
46
|
+
return """
|
|
47
|
+
SELECT uuid
|
|
48
|
+
FROM persistent_command
|
|
49
|
+
WHERE uuid = %(uuid)s
|
|
50
|
+
LIMIT 1 FOR UPDATE NOWAIT;
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def query_terminate_backend() -> str:
|
|
55
|
+
return 'SELECT pg_terminate_backend(%(pid)s);'
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def query_savepoint() -> str:
|
|
59
|
+
return f'SAVEPOINT {JOB_SAVEPOINT};'
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def query_rollback_to_savepoint() -> str:
|
|
63
|
+
return f'ROLLBACK TO SAVEPOINT {JOB_SAVEPOINT};'
|
|
64
|
+
|
|
36
65
|
@staticmethod
|
|
37
66
|
def query_command_error() -> str:
|
|
38
67
|
return """
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dsw-command-queue"
|
|
3
|
+
version = "4.34.0"
|
|
4
|
+
description = "Library for working with command queue and persistent commands"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
keywords = [
|
|
7
|
+
"dsw",
|
|
8
|
+
"subscriber",
|
|
9
|
+
"publisher",
|
|
10
|
+
"database",
|
|
11
|
+
"queue",
|
|
12
|
+
"processing",
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Programming Language :: Python",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Topic :: Database",
|
|
20
|
+
"Topic :: Text Processing",
|
|
21
|
+
"Topic :: Utilities",
|
|
22
|
+
]
|
|
23
|
+
requires-python = ">=3.14, <4"
|
|
24
|
+
dependencies = [
|
|
25
|
+
"psycopg[binary]>=3.2",
|
|
26
|
+
"tenacity",
|
|
27
|
+
"dsw-database==4.34.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.license]
|
|
31
|
+
text = "Apache License 2.0"
|
|
32
|
+
|
|
33
|
+
[[project.authors]]
|
|
34
|
+
name = "Marek Suchánek"
|
|
35
|
+
email = "marek.suchanek@ds-wizard.org"
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://ds-wizard.org"
|
|
39
|
+
Repository = "https://github.com/ds-wizard/engine-tools"
|
|
40
|
+
Documentation = "https://guide.ds-wizard.org"
|
|
41
|
+
Issues = "https://github.com/ds-wizard/ds-wizard/issues"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.12.3,<0.13.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
46
|
+
|
|
47
|
+
[tool.uv.build-backend]
|
|
48
|
+
module-name = "dsw.command_queue"
|
|
49
|
+
module-root = ""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dsw-command-queue"
|
|
3
|
-
version = "4.
|
|
3
|
+
version = "4.34.0"
|
|
4
4
|
description = "Library for working with command queue and persistent commands"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
keywords = ["dsw", "subscriber", "publisher", "database", "queue", "processing"]
|
|
@@ -12,19 +12,17 @@ classifiers = [
|
|
|
12
12
|
"Development Status :: 5 - Production/Stable",
|
|
13
13
|
"License :: OSI Approved :: Apache Software License",
|
|
14
14
|
"Programming Language :: Python",
|
|
15
|
-
"Programming Language :: Python :: 3.12",
|
|
16
|
-
"Programming Language :: Python :: 3.13",
|
|
17
15
|
"Programming Language :: Python :: 3.14",
|
|
18
16
|
"Topic :: Database",
|
|
19
17
|
"Topic :: Text Processing",
|
|
20
18
|
"Topic :: Utilities",
|
|
21
19
|
]
|
|
22
|
-
requires-python = ">=3.
|
|
20
|
+
requires-python = ">=3.14, <4"
|
|
23
21
|
dependencies = [
|
|
24
|
-
"
|
|
22
|
+
"psycopg[binary]>=3.2", # Connection.notifies(timeout=...)
|
|
25
23
|
"tenacity",
|
|
26
24
|
# DSW
|
|
27
|
-
"dsw-database==4.
|
|
25
|
+
"dsw-database==4.34.0",
|
|
28
26
|
]
|
|
29
27
|
|
|
30
28
|
[project.urls]
|
|
@@ -34,7 +32,7 @@ Documentation = "https://guide.ds-wizard.org"
|
|
|
34
32
|
Issues = "https://github.com/ds-wizard/ds-wizard/issues"
|
|
35
33
|
|
|
36
34
|
[build-system]
|
|
37
|
-
requires = ["uv_build>=0.
|
|
35
|
+
requires = ["uv_build>=0.12.3,<0.13.0"]
|
|
38
36
|
build-backend = "uv_build"
|
|
39
37
|
|
|
40
38
|
[tool.uv.build-backend]
|
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import abc
|
|
4
|
-
import datetime
|
|
5
|
-
import logging
|
|
6
|
-
import os
|
|
7
|
-
import platform
|
|
8
|
-
import select
|
|
9
|
-
import signal
|
|
10
|
-
import typing
|
|
11
|
-
|
|
12
|
-
import func_timeout
|
|
13
|
-
import psycopg.generators
|
|
14
|
-
import tenacity
|
|
15
|
-
|
|
16
|
-
from dsw.database.model import PersistentCommand
|
|
17
|
-
|
|
18
|
-
from .query import CommandQueries
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if typing.TYPE_CHECKING:
|
|
22
|
-
from dsw.database import Database
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
LOG = logging.getLogger(__name__)
|
|
26
|
-
|
|
27
|
-
RETRY_QUERY_MULTIPLIER = 0.5
|
|
28
|
-
RETRY_QUERY_TRIES = 3
|
|
29
|
-
RETRY_QUEUE_MULTIPLIER = 0.5
|
|
30
|
-
RETRY_QUEUE_TRIES = 5
|
|
31
|
-
|
|
32
|
-
IS_LINUX = platform == 'Linux'
|
|
33
|
-
|
|
34
|
-
if IS_LINUX:
|
|
35
|
-
_QUEUE_PIPE_R, _QUEUE_PIPE_W = os.pipe()
|
|
36
|
-
signal.set_wakeup_fd(_QUEUE_PIPE_W)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class CommandJobError(BaseException):
|
|
40
|
-
|
|
41
|
-
def __init__(self, job_id: str, message: str, try_again: bool,
|
|
42
|
-
exc: BaseException | None = None):
|
|
43
|
-
self.job_id = job_id
|
|
44
|
-
self.message = message
|
|
45
|
-
self.try_again = try_again
|
|
46
|
-
self.exc = exc
|
|
47
|
-
super().__init__(message)
|
|
48
|
-
|
|
49
|
-
def __str__(self):
|
|
50
|
-
return self.message
|
|
51
|
-
|
|
52
|
-
def log_message(self):
|
|
53
|
-
if self.exc is None:
|
|
54
|
-
return self.message
|
|
55
|
-
return f'{self.message} (caused by: [{type(self.exc).__name__}] {str(self.exc)})'
|
|
56
|
-
|
|
57
|
-
def db_message(self):
|
|
58
|
-
if self.exc is None:
|
|
59
|
-
return self.message
|
|
60
|
-
return f'{self.message}\n\n' \
|
|
61
|
-
f'Caused by: {type(self.exc).__name__}\n' \
|
|
62
|
-
f'{str(self.exc)}'
|
|
63
|
-
|
|
64
|
-
@staticmethod
|
|
65
|
-
def create(job_id: str, message: str, try_again: bool = True,
|
|
66
|
-
exc: BaseException | None = None):
|
|
67
|
-
if isinstance(exc, CommandJobError):
|
|
68
|
-
return exc
|
|
69
|
-
return CommandJobError(
|
|
70
|
-
job_id=job_id,
|
|
71
|
-
message=message,
|
|
72
|
-
try_again=try_again,
|
|
73
|
-
exc=exc,
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
class CommandWorker:
|
|
78
|
-
|
|
79
|
-
@abc.abstractmethod
|
|
80
|
-
def work(self, command: PersistentCommand):
|
|
81
|
-
pass
|
|
82
|
-
|
|
83
|
-
def process_timeout(self, e: BaseException):
|
|
84
|
-
pass
|
|
85
|
-
|
|
86
|
-
def process_exception(self, e: BaseException):
|
|
87
|
-
pass
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
class CommandQueue:
|
|
91
|
-
|
|
92
|
-
def __init__(self, *, worker: CommandWorker, db: Database,
|
|
93
|
-
channel: str, component: str, wait_timeout: float,
|
|
94
|
-
work_timeout: int | None = None):
|
|
95
|
-
self.worker = worker
|
|
96
|
-
self.db = db
|
|
97
|
-
self.queries = CommandQueries(
|
|
98
|
-
channel=channel,
|
|
99
|
-
)
|
|
100
|
-
self.component = component
|
|
101
|
-
self.wait_timeout = wait_timeout
|
|
102
|
-
self.work_timeout = work_timeout
|
|
103
|
-
self._interrupted = False
|
|
104
|
-
|
|
105
|
-
signal.signal(signal.SIGINT, self._signal_handler)
|
|
106
|
-
signal.signal(signal.SIGABRT, self._signal_handler)
|
|
107
|
-
|
|
108
|
-
@tenacity.retry(
|
|
109
|
-
reraise=True,
|
|
110
|
-
wait=tenacity.wait_exponential(multiplier=RETRY_QUEUE_MULTIPLIER),
|
|
111
|
-
stop=tenacity.stop_after_attempt(RETRY_QUEUE_TRIES),
|
|
112
|
-
before=tenacity.before_log(LOG, logging.INFO),
|
|
113
|
-
after=tenacity.after_log(LOG, logging.INFO),
|
|
114
|
-
)
|
|
115
|
-
def run(self):
|
|
116
|
-
LOG.info('Preparing to listen to command queue (issuing LISTEN)')
|
|
117
|
-
queue_conn = self.db.conn_queue
|
|
118
|
-
queue_conn.connection.execute(
|
|
119
|
-
query=self.queries.query_listen().encode(),
|
|
120
|
-
)
|
|
121
|
-
queue_conn.listening = True
|
|
122
|
-
LOG.info('Listening to notifications in command queue')
|
|
123
|
-
fds = [queue_conn.connection.pgconn.socket]
|
|
124
|
-
if IS_LINUX:
|
|
125
|
-
fds.append(_QUEUE_PIPE_R)
|
|
126
|
-
|
|
127
|
-
while True:
|
|
128
|
-
self._fetch_and_process_queued()
|
|
129
|
-
|
|
130
|
-
LOG.info('Waiting for notifications (up to %s seconds)', self.wait_timeout)
|
|
131
|
-
w = select.select(fds, [], [], self.wait_timeout)
|
|
132
|
-
|
|
133
|
-
if self._interrupted:
|
|
134
|
-
LOG.debug('Interrupt signal received, ending...')
|
|
135
|
-
break
|
|
136
|
-
|
|
137
|
-
if w == ([], [], []):
|
|
138
|
-
LOG.info('Nothing received in this cycle (timeout %s seconds)',
|
|
139
|
-
self.wait_timeout)
|
|
140
|
-
else:
|
|
141
|
-
notifications = 0
|
|
142
|
-
for notification in psycopg.generators.notifies(queue_conn.connection.pgconn):
|
|
143
|
-
notifications += 1
|
|
144
|
-
LOG.info('Notification received: %s', notification)
|
|
145
|
-
LOG.info('Notifications received (%s in total)', notifications)
|
|
146
|
-
LOG.info('Exiting command queue')
|
|
147
|
-
|
|
148
|
-
@tenacity.retry(
|
|
149
|
-
reraise=True,
|
|
150
|
-
wait=tenacity.wait_exponential(multiplier=RETRY_QUEUE_MULTIPLIER),
|
|
151
|
-
stop=tenacity.stop_after_attempt(RETRY_QUEUE_TRIES),
|
|
152
|
-
before=tenacity.before_log(LOG, logging.INFO),
|
|
153
|
-
after=tenacity.after_log(LOG, logging.INFO),
|
|
154
|
-
)
|
|
155
|
-
def run_once(self):
|
|
156
|
-
LOG.info('Processing the command queue once')
|
|
157
|
-
self._fetch_and_process_queued()
|
|
158
|
-
|
|
159
|
-
def _fetch_and_process_queued(self):
|
|
160
|
-
LOG.info('Fetching the commands')
|
|
161
|
-
count = 0
|
|
162
|
-
while self.fetch_and_process():
|
|
163
|
-
count += 1
|
|
164
|
-
LOG.info('There are no more commands to process (%s processed)',
|
|
165
|
-
count)
|
|
166
|
-
|
|
167
|
-
def fetch_and_process(self) -> bool:
|
|
168
|
-
cursor = self.db.conn_query.new_cursor(use_dict=True)
|
|
169
|
-
cursor.execute(
|
|
170
|
-
query=self.queries.query_get_command(),
|
|
171
|
-
params={
|
|
172
|
-
'component': self.component,
|
|
173
|
-
'now': datetime.datetime.now(tz=datetime.UTC),
|
|
174
|
-
},
|
|
175
|
-
)
|
|
176
|
-
result = cursor.fetchall()
|
|
177
|
-
if len(result) != 1:
|
|
178
|
-
LOG.info('Fetched %s persistent commands', len(result))
|
|
179
|
-
return False
|
|
180
|
-
|
|
181
|
-
command = PersistentCommand.from_dict_row(result[0])
|
|
182
|
-
LOG.info('Retrieved persistent command %s for processing', command.uuid)
|
|
183
|
-
LOG.info('Previous state: %s', command.state)
|
|
184
|
-
LOG.info('Attempts: %s / %s', command.attempts, command.max_attempts)
|
|
185
|
-
LOG.info('Last error: %s', command.last_error_message)
|
|
186
|
-
|
|
187
|
-
self._process(command)
|
|
188
|
-
|
|
189
|
-
LOG.debug('Committing transaction')
|
|
190
|
-
self.db.conn_query.connection.commit()
|
|
191
|
-
cursor.close()
|
|
192
|
-
LOG.info('Notification processing finished')
|
|
193
|
-
return True
|
|
194
|
-
|
|
195
|
-
def _process(self, command: PersistentCommand):
|
|
196
|
-
attempt_number = command.attempts + 1
|
|
197
|
-
try:
|
|
198
|
-
self.db.execute_query(
|
|
199
|
-
query=self.queries.query_command_start(),
|
|
200
|
-
attempts=attempt_number,
|
|
201
|
-
updated_at=datetime.datetime.now(tz=datetime.UTC),
|
|
202
|
-
uuid=command.uuid,
|
|
203
|
-
)
|
|
204
|
-
self.db.conn_query.connection.commit()
|
|
205
|
-
|
|
206
|
-
def work():
|
|
207
|
-
self.worker.work(command)
|
|
208
|
-
|
|
209
|
-
if self.work_timeout is None:
|
|
210
|
-
LOG.info('Processing (without any timeout set)')
|
|
211
|
-
work()
|
|
212
|
-
else:
|
|
213
|
-
LOG.info('Processing (with timeout set to %s seconds)',
|
|
214
|
-
self.work_timeout)
|
|
215
|
-
func_timeout.func_timeout(
|
|
216
|
-
timeout=self.work_timeout,
|
|
217
|
-
func=work,
|
|
218
|
-
args=(),
|
|
219
|
-
kwargs=None,
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
self.db.execute_query(
|
|
223
|
-
query=self.queries.query_command_done(),
|
|
224
|
-
attempts=attempt_number,
|
|
225
|
-
updated_at=datetime.datetime.now(tz=datetime.UTC),
|
|
226
|
-
uuid=command.uuid,
|
|
227
|
-
)
|
|
228
|
-
except func_timeout.exceptions.FunctionTimedOut as e:
|
|
229
|
-
msg = f'Processing exceeded time limit ({self.work_timeout} seconds)'
|
|
230
|
-
LOG.warning(msg)
|
|
231
|
-
self.worker.process_timeout(e)
|
|
232
|
-
self.db.execute_query(
|
|
233
|
-
query=self.queries.query_command_error(),
|
|
234
|
-
attempts=attempt_number,
|
|
235
|
-
error_message=msg,
|
|
236
|
-
updated_at=datetime.datetime.now(tz=datetime.UTC),
|
|
237
|
-
uuid=command.uuid,
|
|
238
|
-
)
|
|
239
|
-
except CommandJobError as e:
|
|
240
|
-
if e.try_again and attempt_number < command.max_attempts:
|
|
241
|
-
query = self.queries.query_command_error()
|
|
242
|
-
msg = f'Failed with job error: {e.message} (will try again)'
|
|
243
|
-
else:
|
|
244
|
-
query = self.queries.query_command_error_stop()
|
|
245
|
-
msg = f'Failed with job error: {e.message}'
|
|
246
|
-
LOG.warning(msg)
|
|
247
|
-
self.worker.process_exception(e)
|
|
248
|
-
self.db.execute_query(
|
|
249
|
-
query=query,
|
|
250
|
-
attempts=attempt_number,
|
|
251
|
-
error_message=msg,
|
|
252
|
-
updated_at=datetime.datetime.now(tz=datetime.UTC),
|
|
253
|
-
uuid=command.uuid,
|
|
254
|
-
)
|
|
255
|
-
except Exception as e:
|
|
256
|
-
if attempt_number < command.max_attempts:
|
|
257
|
-
msg = f'Failed with exception [{type(e).__name__}]: {str(e)} (will try again)'
|
|
258
|
-
else:
|
|
259
|
-
msg = f'Failed with exception [{type(e).__name__}]: {str(e)}'
|
|
260
|
-
LOG.warning(msg)
|
|
261
|
-
self.worker.process_exception(e)
|
|
262
|
-
self.db.execute_query(
|
|
263
|
-
query=self.queries.query_command_error(),
|
|
264
|
-
attempts=attempt_number,
|
|
265
|
-
error_message=msg,
|
|
266
|
-
updated_at=datetime.datetime.now(tz=datetime.UTC),
|
|
267
|
-
uuid=command.uuid,
|
|
268
|
-
)
|
|
269
|
-
|
|
270
|
-
def _signal_handler(self, recv_signal, frame):
|
|
271
|
-
LOG.warning('Received interrupt signal: %s (frame: %s)',
|
|
272
|
-
recv_signal, frame)
|
|
273
|
-
self._interrupted = True
|
|
File without changes
|
|
File without changes
|