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.
- dsw/command_queue/__init__.py +4 -0
- dsw/command_queue/build_info.py +17 -0
- dsw/command_queue/command_queue.py +267 -0
- dsw/command_queue/py.typed +0 -0
- dsw/command_queue/query.py +74 -0
- dsw_command_queue-4.27.0.dist-info/METADATA +44 -0
- dsw_command_queue-4.27.0.dist-info/RECORD +8 -0
- dsw_command_queue-4.27.0.dist-info/WHEEL +4 -0
|
@@ -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
|
+
[](https://github.com/ds-wizard/engine-tools/releases)
|
|
29
|
+
[](https://pypi.org/project/dsw-command-queue/)
|
|
30
|
+
[](LICENSE)
|
|
31
|
+
[](https://bestpractices.coreinfrastructure.org/projects/4975)
|
|
32
|
+
[](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,,
|