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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dsw-command-queue
3
- Version: 4.32.0
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: func-timeout
16
+ Requires-Dist: psycopg[binary]>=3.2
19
17
  Requires-Dist: tenacity
20
- Requires-Dist: dsw-database==4.32.0
21
- Requires-Python: >=3.12, <4
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.32.0~7ad5ac9',
13
- built_at='2026-07-07 05:32:52Z',
14
- sha='7ad5ac9f39265ed2db9c8dba2c0e9a94de79227d',
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.32.0',
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
- def query_get_command(self) -> str:
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 (created_at AT TIME ZONE 'UTC')
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.32.0"
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.12, <4"
20
+ requires-python = ">=3.14, <4"
23
21
  dependencies = [
24
- "func-timeout",
22
+ "psycopg[binary]>=3.2", # Connection.notifies(timeout=...)
25
23
  "tenacity",
26
24
  # DSW
27
- "dsw-database==4.32.0",
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.11.0,<0.12.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,4 +0,0 @@
1
- from .command_queue import CommandJobError, CommandQueue, CommandWorker
2
-
3
-
4
- __all__ = ['CommandJobError', 'CommandQueue', 'CommandWorker']
@@ -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