dsw-command-queue 4.27.0__py3-none-any.whl

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.
@@ -0,0 +1,4 @@
1
+ from .command_queue import CommandJobError, CommandQueue, CommandWorker
2
+
3
+
4
+ __all__ = ['CommandJobError', 'CommandQueue', 'CommandWorker']
@@ -0,0 +1,17 @@
1
+ # Generated file
2
+ # - do not overwrite
3
+ # - do not include in git
4
+ from collections import namedtuple
5
+
6
+ BuildInfo = namedtuple(
7
+ 'BuildInfo',
8
+ ['version', 'built_at', 'sha', 'branch', 'tag'],
9
+ )
10
+
11
+ BUILD_INFO = BuildInfo(
12
+ version='v4.27.0~8ec71bd',
13
+ built_at='2026-02-03 08:43:16Z',
14
+ sha='8ec71bd85dfbea66adedb6590f7d76ae5143bbaa',
15
+ branch='HEAD',
16
+ tag='v4.27.0',
17
+ )
@@ -0,0 +1,267 @@
1
+ import abc
2
+ import datetime
3
+ import logging
4
+ import os
5
+ import platform
6
+ import select
7
+ import signal
8
+
9
+ import func_timeout
10
+ import psycopg.generators
11
+ import tenacity
12
+
13
+ from dsw.database import Database
14
+ from dsw.database.model import PersistentCommand
15
+
16
+ from .query import CommandQueries
17
+
18
+
19
+ LOG = logging.getLogger(__name__)
20
+
21
+ RETRY_QUERY_MULTIPLIER = 0.5
22
+ RETRY_QUERY_TRIES = 3
23
+ RETRY_QUEUE_MULTIPLIER = 0.5
24
+ RETRY_QUEUE_TRIES = 5
25
+
26
+ IS_LINUX = platform == 'Linux'
27
+
28
+ if IS_LINUX:
29
+ _QUEUE_PIPE_R, _QUEUE_PIPE_W = os.pipe()
30
+ signal.set_wakeup_fd(_QUEUE_PIPE_W)
31
+
32
+
33
+ class CommandJobError(BaseException):
34
+
35
+ def __init__(self, job_id: str, message: str, try_again: bool,
36
+ exc: BaseException | None = None):
37
+ self.job_id = job_id
38
+ self.message = message
39
+ self.try_again = try_again
40
+ self.exc = exc
41
+ super().__init__(message)
42
+
43
+ def __str__(self):
44
+ return self.message
45
+
46
+ def log_message(self):
47
+ if self.exc is None:
48
+ return self.message
49
+ return f'{self.message} (caused by: [{type(self.exc).__name__}] {str(self.exc)})'
50
+
51
+ def db_message(self):
52
+ if self.exc is None:
53
+ return self.message
54
+ return f'{self.message}\n\n' \
55
+ f'Caused by: {type(self.exc).__name__}\n' \
56
+ f'{str(self.exc)}'
57
+
58
+ @staticmethod
59
+ def create(job_id: str, message: str, try_again: bool = True,
60
+ exc: BaseException | None = None):
61
+ if isinstance(exc, CommandJobError):
62
+ return exc
63
+ return CommandJobError(
64
+ job_id=job_id,
65
+ message=message,
66
+ try_again=try_again,
67
+ exc=exc,
68
+ )
69
+
70
+
71
+ class CommandWorker:
72
+
73
+ @abc.abstractmethod
74
+ def work(self, command: PersistentCommand):
75
+ pass
76
+
77
+ def process_timeout(self, e: BaseException):
78
+ pass
79
+
80
+ def process_exception(self, e: BaseException):
81
+ pass
82
+
83
+
84
+ class CommandQueue:
85
+
86
+ def __init__(self, *, worker: CommandWorker, db: Database,
87
+ channel: str, component: str, wait_timeout: float,
88
+ work_timeout: int | None = None):
89
+ self.worker = worker
90
+ self.db = db
91
+ self.queries = CommandQueries(
92
+ channel=channel,
93
+ )
94
+ self.component = component
95
+ self.wait_timeout = wait_timeout
96
+ self.work_timeout = work_timeout
97
+ self._interrupted = False
98
+
99
+ signal.signal(signal.SIGINT, self._signal_handler)
100
+ signal.signal(signal.SIGABRT, self._signal_handler)
101
+
102
+ @tenacity.retry(
103
+ reraise=True,
104
+ wait=tenacity.wait_exponential(multiplier=RETRY_QUEUE_MULTIPLIER),
105
+ stop=tenacity.stop_after_attempt(RETRY_QUEUE_TRIES),
106
+ before=tenacity.before_log(LOG, logging.INFO),
107
+ after=tenacity.after_log(LOG, logging.INFO),
108
+ )
109
+ def run(self):
110
+ LOG.info('Preparing to listen to command queue (issuing LISTEN)')
111
+ queue_conn = self.db.conn_queue
112
+ queue_conn.connection.execute(
113
+ query=self.queries.query_listen().encode(),
114
+ )
115
+ queue_conn.listening = True
116
+ LOG.info('Listening to notifications in command queue')
117
+ fds = [queue_conn.connection.pgconn.socket]
118
+ if IS_LINUX:
119
+ fds.append(_QUEUE_PIPE_R)
120
+
121
+ while True:
122
+ self._fetch_and_process_queued()
123
+
124
+ LOG.info('Waiting for notifications (up to %s seconds)', self.wait_timeout)
125
+ w = select.select(fds, [], [], self.wait_timeout)
126
+
127
+ if self._interrupted:
128
+ LOG.debug('Interrupt signal received, ending...')
129
+ break
130
+
131
+ if w == ([], [], []):
132
+ LOG.info('Nothing received in this cycle (timeout %s seconds)',
133
+ self.wait_timeout)
134
+ else:
135
+ notifications = 0
136
+ for notification in psycopg.generators.notifies(queue_conn.connection.pgconn):
137
+ notifications += 1
138
+ LOG.info('Notification received: %s', notification)
139
+ LOG.info('Notifications received (%s in total)', notifications)
140
+ LOG.info('Exiting command queue')
141
+
142
+ @tenacity.retry(
143
+ reraise=True,
144
+ wait=tenacity.wait_exponential(multiplier=RETRY_QUEUE_MULTIPLIER),
145
+ stop=tenacity.stop_after_attempt(RETRY_QUEUE_TRIES),
146
+ before=tenacity.before_log(LOG, logging.INFO),
147
+ after=tenacity.after_log(LOG, logging.INFO),
148
+ )
149
+ def run_once(self):
150
+ LOG.info('Processing the command queue once')
151
+ self._fetch_and_process_queued()
152
+
153
+ def _fetch_and_process_queued(self):
154
+ LOG.info('Fetching the commands')
155
+ count = 0
156
+ while self.fetch_and_process():
157
+ count += 1
158
+ LOG.info('There are no more commands to process (%s processed)',
159
+ count)
160
+
161
+ def fetch_and_process(self) -> bool:
162
+ cursor = self.db.conn_query.new_cursor(use_dict=True)
163
+ cursor.execute(
164
+ query=self.queries.query_get_command(),
165
+ params={
166
+ 'component': self.component,
167
+ 'now': datetime.datetime.now(tz=datetime.UTC),
168
+ },
169
+ )
170
+ result = cursor.fetchall()
171
+ if len(result) != 1:
172
+ LOG.info('Fetched %s persistent commands', len(result))
173
+ return False
174
+
175
+ command = PersistentCommand.from_dict_row(result[0])
176
+ LOG.info('Retrieved persistent command %s for processing', command.uuid)
177
+ LOG.info('Previous state: %s', command.state)
178
+ LOG.info('Attempts: %s / %s', command.attempts, command.max_attempts)
179
+ LOG.info('Last error: %s', command.last_error_message)
180
+
181
+ self._process(command)
182
+
183
+ LOG.debug('Committing transaction')
184
+ self.db.conn_query.connection.commit()
185
+ cursor.close()
186
+ LOG.info('Notification processing finished')
187
+ return True
188
+
189
+ def _process(self, command: PersistentCommand):
190
+ attempt_number = command.attempts + 1
191
+ try:
192
+ self.db.execute_query(
193
+ query=self.queries.query_command_start(),
194
+ attempts=attempt_number,
195
+ updated_at=datetime.datetime.now(tz=datetime.UTC),
196
+ uuid=command.uuid,
197
+ )
198
+ self.db.conn_query.connection.commit()
199
+
200
+ def work():
201
+ self.worker.work(command)
202
+
203
+ if self.work_timeout is None:
204
+ LOG.info('Processing (without any timeout set)')
205
+ work()
206
+ else:
207
+ LOG.info('Processing (with timeout set to %s seconds)',
208
+ self.work_timeout)
209
+ func_timeout.func_timeout(
210
+ timeout=self.work_timeout,
211
+ func=work,
212
+ args=(),
213
+ kwargs=None,
214
+ )
215
+
216
+ self.db.execute_query(
217
+ query=self.queries.query_command_done(),
218
+ attempts=attempt_number,
219
+ updated_at=datetime.datetime.now(tz=datetime.UTC),
220
+ uuid=command.uuid,
221
+ )
222
+ except func_timeout.exceptions.FunctionTimedOut as e:
223
+ msg = f'Processing exceeded time limit ({self.work_timeout} seconds)'
224
+ LOG.warning(msg)
225
+ self.worker.process_timeout(e)
226
+ self.db.execute_query(
227
+ query=self.queries.query_command_error(),
228
+ attempts=attempt_number,
229
+ error_message=msg,
230
+ updated_at=datetime.datetime.now(tz=datetime.UTC),
231
+ uuid=command.uuid,
232
+ )
233
+ except CommandJobError as e:
234
+ if e.try_again and attempt_number < command.max_attempts:
235
+ query = self.queries.query_command_error()
236
+ msg = f'Failed with job error: {e.message} (will try again)'
237
+ else:
238
+ query = self.queries.query_command_error_stop()
239
+ msg = f'Failed with job error: {e.message}'
240
+ LOG.warning(msg)
241
+ self.worker.process_exception(e)
242
+ self.db.execute_query(
243
+ query=query,
244
+ attempts=attempt_number,
245
+ error_message=msg,
246
+ updated_at=datetime.datetime.now(tz=datetime.UTC),
247
+ uuid=command.uuid,
248
+ )
249
+ except Exception as e:
250
+ if attempt_number < command.max_attempts:
251
+ msg = f'Failed with exception [{type(e).__name__}]: {str(e)} (will try again)'
252
+ else:
253
+ msg = f'Failed with exception [{type(e).__name__}]: {str(e)}'
254
+ LOG.warning(msg)
255
+ self.worker.process_exception(e)
256
+ self.db.execute_query(
257
+ query=self.queries.query_command_error(),
258
+ attempts=attempt_number,
259
+ error_message=msg,
260
+ updated_at=datetime.datetime.now(tz=datetime.UTC),
261
+ uuid=command.uuid,
262
+ )
263
+
264
+ def _signal_handler(self, recv_signal, frame):
265
+ LOG.warning('Received interrupt signal: %s (frame: %s)',
266
+ recv_signal, frame)
267
+ self._interrupted = True
File without changes
@@ -0,0 +1,74 @@
1
+ import enum
2
+
3
+
4
+ class CommandState(enum.Enum):
5
+ NEW = 'NewPersistentCommandState'
6
+ DONE = 'DonePersistentCommandState'
7
+ ERROR = 'ErrorPersistentCommandState'
8
+ IGNORE = 'IgnorePersistentCommandState'
9
+
10
+
11
+ class CommandQueries:
12
+
13
+ def __init__(self, channel: str):
14
+ self.channel = channel
15
+
16
+ def query_listen(self) -> str:
17
+ return f'LISTEN persistent_command_channel__{self.channel};'
18
+
19
+ def query_get_command(self) -> str:
20
+ return """
21
+ SELECT *
22
+ FROM persistent_command
23
+ WHERE component = %(component)s
24
+ AND attempts < max_attempts
25
+ AND state != 'DonePersistentCommandState'
26
+ AND state != 'IgnorePersistentCommandState'
27
+ AND (created_at AT TIME ZONE 'UTC')
28
+ <
29
+ (%(now)s - (2 ^ attempts - 1) * INTERVAL '1 min')
30
+ ORDER BY attempts ASC, updated_at DESC
31
+ LIMIT 1 FOR UPDATE SKIP LOCKED;
32
+ """
33
+
34
+ @staticmethod
35
+ def query_command_error() -> str:
36
+ return """
37
+ UPDATE persistent_command
38
+ SET attempts = %(attempts)s,
39
+ last_error_message = %(error_message)s,
40
+ state = 'ErrorPersistentCommandState',
41
+ updated_at = %(updated_at)s
42
+ WHERE uuid = %(uuid)s;
43
+ """
44
+
45
+ @staticmethod
46
+ def query_command_error_stop() -> str:
47
+ return """
48
+ UPDATE persistent_command
49
+ SET attempts = %(attempts)s,
50
+ max_attempts = %(attempts)s,
51
+ last_error_message = %(error_message)s,
52
+ state = 'ErrorPersistentCommandState',
53
+ updated_at = %(updated_at)s
54
+ WHERE uuid = %(uuid)s;
55
+ """
56
+
57
+ @staticmethod
58
+ def query_command_done() -> str:
59
+ return """
60
+ UPDATE persistent_command
61
+ SET attempts = %(attempts)s,
62
+ state = 'DonePersistentCommandState',
63
+ updated_at = %(updated_at)s
64
+ WHERE uuid = %(uuid)s;
65
+ """
66
+
67
+ @staticmethod
68
+ def query_command_start() -> str:
69
+ return """
70
+ UPDATE persistent_command
71
+ SET attempts = %(attempts)s,
72
+ updated_at = %(updated_at)s
73
+ WHERE uuid = %(uuid)s;
74
+ """
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.3
2
+ Name: dsw-command-queue
3
+ Version: 4.27.0
4
+ Summary: Library for working with command queue and persistent commands
5
+ Keywords: dsw,subscriber,publisher,database,queue,processing
6
+ Author: Marek Suchánek
7
+ Author-email: Marek Suchánek <marek.suchanek@ds-wizard.org>
8
+ License: Apache License 2.0
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Database
15
+ Classifier: Topic :: Text Processing
16
+ Classifier: Topic :: Utilities
17
+ Requires-Dist: func-timeout
18
+ Requires-Dist: dsw-database==4.27.0
19
+ Requires-Python: >=3.12, <4
20
+ Project-URL: Homepage, https://ds-wizard.org
21
+ Project-URL: Repository, https://github.com/ds-wizard/engine-tools
22
+ Project-URL: Documentation, https://guide.ds-wizard.org
23
+ Project-URL: Issues, https://github.com/ds-wizard/ds-wizard/issues
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Data Stewardship Wizard: Command Queue
27
+
28
+ [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/ds-wizard/engine-tools)](https://github.com/ds-wizard/engine-tools/releases)
29
+ [![PyPI](https://img.shields.io/pypi/v/dsw-command-queue)](https://pypi.org/project/dsw-command-queue/)
30
+ [![LICENSE](https://img.shields.io/github/license/ds-wizard/engine-tools)](LICENSE)
31
+ [![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/4975/badge)](https://bestpractices.coreinfrastructure.org/projects/4975)
32
+ [![Python Version](https://img.shields.io/badge/Python-%E2%89%A5%203.7-blue)](https://python.org)
33
+
34
+ *Library for working with DSW command queue and persistent commands*
35
+
36
+ ## Usage
37
+
38
+ Currently, this library is intended for internal use of DSW tooling only.
39
+ Enhancements for use in custom scripts are planned for future development.
40
+
41
+ ## License
42
+
43
+ This project is licensed under the Apache License v2.0 - see the
44
+ [LICENSE](LICENSE) file for more details.
@@ -0,0 +1,8 @@
1
+ dsw/command_queue/__init__.py,sha256=JmYcLGvSusyDAhoKwM9u5VgK2Zzv7h4MrMiThOuMRJ4,137
2
+ dsw/command_queue/build_info.py,sha256=HmTWdilH1Z3vzqKd83M66jFCU1MwfhLHj2ugpm84buA,381
3
+ dsw/command_queue/command_queue.py,sha256=l1U-8hvxf3hWdKq8kw5QNfiPUCfKNIIoi3jizYZMhx8,9090
4
+ dsw/command_queue/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ dsw/command_queue/query.py,sha256=s1KlaU6ttFt8ily2xykfCGZHoUYOnLQi4h72Cz7cBzw,2281
6
+ dsw_command_queue-4.27.0.dist-info/WHEEL,sha256=fAguSjoiATBe7TNBkJwOjyL1Tt4wwiaQGtNtjRPNMQA,80
7
+ dsw_command_queue-4.27.0.dist-info/METADATA,sha256=LnIbJVIi37lmR0dLKWtwzDuafZJhuskIC_cZfxuGxBw,2019
8
+ dsw_command_queue-4.27.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.28
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any