PgQueuer 0.2.2__tar.gz → 0.2.4__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.
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/PKG-INFO +3 -1
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/pyproject.toml +2 -0
- pgqueuer-0.2.4/src/PgQueuer/__main__.py +8 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/cli.py +92 -3
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/models.py +6 -3
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/qm.py +23 -17
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/queries.py +88 -38
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer.egg-info/PKG-INFO +3 -1
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer.egg-info/requires.txt +2 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/_version.py +2 -2
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/test/test_qm.py +15 -6
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/test/test_queries.py +38 -17
- pgqueuer-0.2.4/tools/benchmark.py +100 -0
- pgqueuer-0.2.2/src/PgQueuer/__main__.py +0 -6
- pgqueuer-0.2.2/tools/benchmark.py +0 -99
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/.github/workflows/ci.yml +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/.github/workflows/linting.yml +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/.github/workflows/release.yml +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/.gitignore +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/LICENSE +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/README.md +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/docker-compose.yml +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/setup.cfg +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/__init__.py +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/_version.py +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/logconfig.py +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/py.typed +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/tm.py +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer.egg-info/SOURCES.txt +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer.egg-info/dependency_links.txt +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer.egg-info/top_level.txt +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/test/conftest.py +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/test/db/Dockerfile +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/test/db/init_db.sh +0 -0
- {pgqueuer-0.2.2 → pgqueuer-0.2.4}/test/test_tm.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: PgQueuer
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: PgQueuer is a Python library leveraging PostgreSQL for efficient job queuing.
|
|
5
5
|
Author: janbjorge
|
|
6
6
|
License: MIT License
|
|
@@ -30,6 +30,7 @@ Requires-Dist: anyio>=4.0
|
|
|
30
30
|
Requires-Dist: asyncpg>=0.27.0
|
|
31
31
|
Requires-Dist: pgcachewatch>=0.4
|
|
32
32
|
Requires-Dist: pydantic>=2.0.0
|
|
33
|
+
Requires-Dist: tabulate>=0.9.0
|
|
33
34
|
Provides-Extra: dev
|
|
34
35
|
Requires-Dist: asyncpg-stubs; extra == "dev"
|
|
35
36
|
Requires-Dist: mypy-extensions; extra == "dev"
|
|
@@ -37,6 +38,7 @@ Requires-Dist: mypy; extra == "dev"
|
|
|
37
38
|
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
38
39
|
Requires-Dist: pytest; extra == "dev"
|
|
39
40
|
Requires-Dist: ruff; extra == "dev"
|
|
41
|
+
Requires-Dist: types-tabulate; extra == "dev"
|
|
40
42
|
|
|
41
43
|
## 🚀 PgQueuer - Building Smoother Workflows One Queue at a Time 🚀
|
|
42
44
|
[](https://github.com/janbjorge/PgQueuer/actions/workflows/ci.yml?query=branch%3Amain)
|
|
@@ -34,6 +34,7 @@ dependencies = [
|
|
|
34
34
|
"asyncpg>=0.27.0",
|
|
35
35
|
"pgcachewatch>=0.4",
|
|
36
36
|
"pydantic>=2.0.0",
|
|
37
|
+
"tabulate>=0.9.0",
|
|
37
38
|
]
|
|
38
39
|
|
|
39
40
|
[project.urls]
|
|
@@ -50,6 +51,7 @@ dev = [
|
|
|
50
51
|
"pytest-asyncio",
|
|
51
52
|
"pytest",
|
|
52
53
|
"ruff",
|
|
54
|
+
"types-tabulate",
|
|
53
55
|
]
|
|
54
56
|
|
|
55
57
|
[tool.setuptools_scm]
|
|
@@ -1,9 +1,59 @@
|
|
|
1
1
|
import argparse
|
|
2
|
+
import asyncio
|
|
2
3
|
import os
|
|
4
|
+
from datetime import timedelta
|
|
3
5
|
|
|
4
6
|
import asyncpg
|
|
7
|
+
from tabulate import tabulate, tabulate_formats
|
|
8
|
+
|
|
9
|
+
from PgQueuer.models import LogStatistics
|
|
10
|
+
from PgQueuer.queries import Queries
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def display_stats(
|
|
14
|
+
log_stats: list[LogStatistics],
|
|
15
|
+
tablefmt: str,
|
|
16
|
+
) -> None:
|
|
17
|
+
print(
|
|
18
|
+
tabulate(
|
|
19
|
+
[
|
|
20
|
+
(
|
|
21
|
+
stat.created,
|
|
22
|
+
stat.count,
|
|
23
|
+
stat.entrypoint,
|
|
24
|
+
stat.time_in_queue,
|
|
25
|
+
stat.status,
|
|
26
|
+
stat.priority,
|
|
27
|
+
)
|
|
28
|
+
for stat in log_stats
|
|
29
|
+
],
|
|
30
|
+
headers=[
|
|
31
|
+
"Created",
|
|
32
|
+
"Count",
|
|
33
|
+
"Entrypoint",
|
|
34
|
+
"Time in Queue (HH:MM:SS)",
|
|
35
|
+
"Status",
|
|
36
|
+
"Priority",
|
|
37
|
+
],
|
|
38
|
+
tablefmt=tablefmt,
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
5
42
|
|
|
6
|
-
|
|
43
|
+
async def fetch_and_dispay(
|
|
44
|
+
queries: Queries,
|
|
45
|
+
interval: timedelta | None,
|
|
46
|
+
tail: int,
|
|
47
|
+
tablefmt: str,
|
|
48
|
+
) -> None:
|
|
49
|
+
clear_and_home = "\033[2J\033[H"
|
|
50
|
+
while True:
|
|
51
|
+
print(clear_and_home, end="")
|
|
52
|
+
stats = await queries.log_statistics(tail) # Fetch stats once and reuse
|
|
53
|
+
await display_stats(stats, tablefmt)
|
|
54
|
+
if interval is None:
|
|
55
|
+
return
|
|
56
|
+
await asyncio.sleep(interval.total_seconds())
|
|
7
57
|
|
|
8
58
|
|
|
9
59
|
def cliparser() -> argparse.Namespace:
|
|
@@ -100,6 +150,35 @@ def cliparser() -> argparse.Namespace:
|
|
|
100
150
|
parents=[common_arguments],
|
|
101
151
|
)
|
|
102
152
|
|
|
153
|
+
dashboardparser = subparsers.add_parser(
|
|
154
|
+
"dashboard",
|
|
155
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
156
|
+
parents=[common_arguments],
|
|
157
|
+
)
|
|
158
|
+
dashboardparser.add_argument(
|
|
159
|
+
"-i",
|
|
160
|
+
"--interval",
|
|
161
|
+
help="Set the refresh interval in seconds for updating the dashboard display.",
|
|
162
|
+
default=None,
|
|
163
|
+
type=lambda x: timedelta(seconds=float(x)),
|
|
164
|
+
)
|
|
165
|
+
dashboardparser.add_argument(
|
|
166
|
+
"-n",
|
|
167
|
+
"--tail",
|
|
168
|
+
help="The number of the most recent log entries to display on the dashboard.",
|
|
169
|
+
type=int,
|
|
170
|
+
default=25,
|
|
171
|
+
)
|
|
172
|
+
dashboardparser.add_argument(
|
|
173
|
+
"--table-format",
|
|
174
|
+
default="pretty",
|
|
175
|
+
help=(
|
|
176
|
+
"Specify the format of the table used to display statistics. "
|
|
177
|
+
"Options are provided by the tabulate library."
|
|
178
|
+
),
|
|
179
|
+
choices=tabulate_formats,
|
|
180
|
+
)
|
|
181
|
+
|
|
103
182
|
return parser.parse_args()
|
|
104
183
|
|
|
105
184
|
|
|
@@ -120,9 +199,19 @@ async def main() -> None:
|
|
|
120
199
|
port=parsed.pg_port,
|
|
121
200
|
user=parsed.pg_user,
|
|
122
201
|
host=parsed.pg_host,
|
|
202
|
+
max_size=1,
|
|
203
|
+
min_size=0,
|
|
123
204
|
) as pool:
|
|
205
|
+
queries = Queries(pool)
|
|
124
206
|
match parsed.command:
|
|
125
207
|
case "install":
|
|
126
|
-
await queries.
|
|
208
|
+
await queries.install()
|
|
127
209
|
case "uninstall":
|
|
128
|
-
await queries.
|
|
210
|
+
await queries.uninstall()
|
|
211
|
+
case "dashboard":
|
|
212
|
+
await fetch_and_dispay(
|
|
213
|
+
queries,
|
|
214
|
+
parsed.interval,
|
|
215
|
+
parsed.tail,
|
|
216
|
+
parsed.table_format,
|
|
217
|
+
)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
1
2
|
from typing import Literal
|
|
2
3
|
|
|
3
4
|
from pydantic import AwareDatetime, BaseModel
|
|
@@ -26,7 +27,7 @@ class Job(BaseModel):
|
|
|
26
27
|
payload: bytes | None
|
|
27
28
|
|
|
28
29
|
|
|
29
|
-
class
|
|
30
|
+
class QueueStatistics(BaseModel):
|
|
30
31
|
"""
|
|
31
32
|
Represents the number of jobs per entrypoint and priority in the queue.
|
|
32
33
|
"""
|
|
@@ -36,12 +37,14 @@ class QueueSize(BaseModel):
|
|
|
36
37
|
priority: int
|
|
37
38
|
|
|
38
39
|
|
|
39
|
-
class
|
|
40
|
+
class LogStatistics(BaseModel):
|
|
40
41
|
"""
|
|
41
|
-
Represents log
|
|
42
|
+
Represents log statistics for jobs based on status, entrypoint, and priority.
|
|
42
43
|
"""
|
|
43
44
|
|
|
44
45
|
count: int
|
|
46
|
+
created: AwareDatetime
|
|
45
47
|
entrypoint: str
|
|
46
48
|
priority: int
|
|
47
49
|
status: STATUS_LOG
|
|
50
|
+
time_in_queue: timedelta
|
|
@@ -41,8 +41,19 @@ def is_async_callable(obj: SyncEntrypoint) -> TypeGuard[SyncEntrypoint]: ...
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def is_async_callable(obj: object) -> bool:
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
"""
|
|
45
|
+
Determines whether an object is an asynchronous callable.
|
|
46
|
+
This function identifies objects that are either asynchronous coroutine functions
|
|
47
|
+
or have a callable __call__ method that is an asynchronous coroutine function.
|
|
48
|
+
|
|
49
|
+
The function handles functools.partial objects by evaluating the
|
|
50
|
+
underlying function. It supports overloads to ensure type-specific behavior
|
|
51
|
+
with AsyncEntrypoint and SyncEntrypoint types.
|
|
52
|
+
|
|
53
|
+
Inspired by:
|
|
54
|
+
https://github.com/encode/starlette/blob/9f16bf5c25e126200701f6e04330864f4a91a898/starlette/_utils.py#L38
|
|
55
|
+
"""
|
|
56
|
+
|
|
46
57
|
while isinstance(obj, functools.partial):
|
|
47
58
|
obj = obj.func
|
|
48
59
|
|
|
@@ -60,17 +71,12 @@ class QueueManager:
|
|
|
60
71
|
|
|
61
72
|
pool: asyncpg.Pool
|
|
62
73
|
queries: Queries = dataclasses.field(init=False)
|
|
63
|
-
channel: PGChannel = dataclasses.field(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
alive: bool = dataclasses.field(
|
|
67
|
-
init=False,
|
|
68
|
-
default=True,
|
|
69
|
-
)
|
|
74
|
+
channel: PGChannel = dataclasses.field(default=PGChannel(DBSettings().channel))
|
|
75
|
+
alive: bool = dataclasses.field(init=False, default=True)
|
|
76
|
+
|
|
70
77
|
# Should registry be a weakref?
|
|
71
78
|
registry: dict[str, Entrypoint] = dataclasses.field(
|
|
72
|
-
init=False,
|
|
73
|
-
default_factory=dict,
|
|
79
|
+
init=False, default_factory=dict
|
|
74
80
|
)
|
|
75
81
|
|
|
76
82
|
def __post_init__(self) -> None:
|
|
@@ -84,8 +90,8 @@ class QueueManager:
|
|
|
84
90
|
|
|
85
91
|
def entrypoint(self, name: str) -> Callable[[T], T]:
|
|
86
92
|
"""
|
|
87
|
-
|
|
88
|
-
|
|
93
|
+
Registers a function as an entrypoint for handling specific
|
|
94
|
+
job types. Ensures unique naming in the registry.
|
|
89
95
|
"""
|
|
90
96
|
|
|
91
97
|
if name in self.registry:
|
|
@@ -102,8 +108,8 @@ class QueueManager:
|
|
|
102
108
|
dequeue_timeout: timedelta = timedelta(seconds=30),
|
|
103
109
|
) -> None:
|
|
104
110
|
"""
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
Continuously listens for events and dispatches jobs. Manages connections and
|
|
112
|
+
tasks, logs timeouts, and resets connections upon termination.
|
|
107
113
|
"""
|
|
108
114
|
async with (
|
|
109
115
|
self.pool.acquire() as conn,
|
|
@@ -131,8 +137,8 @@ class QueueManager:
|
|
|
131
137
|
|
|
132
138
|
async def _dispatch(self, job: Job) -> None:
|
|
133
139
|
"""
|
|
134
|
-
|
|
135
|
-
|
|
140
|
+
Handles asynchronous job dispatch. Logs exceptions, updates job status,
|
|
141
|
+
and adapts execution method based on whether the job's function is asynchronous.
|
|
136
142
|
"""
|
|
137
143
|
|
|
138
144
|
logger.debug(
|
|
@@ -31,12 +31,11 @@ class DBSettings:
|
|
|
31
31
|
default_factory=lambda: add_prefix("fn_pgqueuer_changed"),
|
|
32
32
|
kw_only=True,
|
|
33
33
|
)
|
|
34
|
-
|
|
35
|
-
default_factory=lambda: add_prefix("
|
|
36
|
-
kw_only=True,
|
|
34
|
+
statistics_table: Final[str] = dataclasses.field(
|
|
35
|
+
default_factory=lambda: add_prefix("pgqueuer_statistics"),
|
|
37
36
|
)
|
|
38
|
-
|
|
39
|
-
default_factory=lambda: add_prefix("
|
|
37
|
+
statistics_table_status_type: Final[str] = dataclasses.field(
|
|
38
|
+
default_factory=lambda: add_prefix("pgqueuer_statistics_status"),
|
|
40
39
|
kw_only=True,
|
|
41
40
|
)
|
|
42
41
|
queue_status_type: Final[str] = dataclasses.field(
|
|
@@ -74,7 +73,7 @@ class Queries:
|
|
|
74
73
|
CREATE TABLE {self.settings.queue_table} (
|
|
75
74
|
id SERIAL PRIMARY KEY,
|
|
76
75
|
priority INT NOT NULL,
|
|
77
|
-
created TIMESTAMP WITH TIME ZONE
|
|
76
|
+
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
78
77
|
status {self.settings.queue_status_type} NOT NULL,
|
|
79
78
|
entrypoint TEXT NOT NULL,
|
|
80
79
|
payload BYTEA
|
|
@@ -82,15 +81,23 @@ class Queries:
|
|
|
82
81
|
CREATE INDEX ON {self.settings.queue_table} (priority ASC, id DESC)
|
|
83
82
|
INCLUDE (id) WHERE status = 'queued';
|
|
84
83
|
|
|
85
|
-
CREATE TYPE {self.settings.
|
|
86
|
-
CREATE TABLE {self.settings.
|
|
84
|
+
CREATE TYPE {self.settings.statistics_table_status_type} AS ENUM ('exception', 'successful');
|
|
85
|
+
CREATE TABLE {self.settings.statistics_table} (
|
|
87
86
|
id SERIAL PRIMARY KEY,
|
|
87
|
+
created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT DATE_TRUNC('sec', CURRENT_TIMESTAMP at time zone 'UTC'),
|
|
88
|
+
count BIGINT NOT NULL,
|
|
88
89
|
priority INT NOT NULL,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
status {self.settings.log_table_status_type} NOT NULL,
|
|
90
|
+
time_in_queue INTERVAL NOT NULL,
|
|
91
|
+
status {self.settings.statistics_table_status_type} NOT NULL,
|
|
92
92
|
entrypoint TEXT NOT NULL
|
|
93
93
|
);
|
|
94
|
+
CREATE UNIQUE INDEX unique_count ON {self.settings.statistics_table} (
|
|
95
|
+
priority,
|
|
96
|
+
DATE_TRUNC('sec', created at time zone 'UTC'),
|
|
97
|
+
DATE_TRUNC('sec', time_in_queue),
|
|
98
|
+
status,
|
|
99
|
+
entrypoint
|
|
100
|
+
);
|
|
94
101
|
|
|
95
102
|
CREATE FUNCTION {self.settings.function}() RETURNS TRIGGER AS $$
|
|
96
103
|
DECLARE
|
|
@@ -148,9 +155,9 @@ class Queries:
|
|
|
148
155
|
DROP TRIGGER {self.settings.trigger};
|
|
149
156
|
DROP FUNCTION {self.settings.function};
|
|
150
157
|
DROP TABLE {self.settings.queue_table};
|
|
151
|
-
DROP TABLE {self.settings.
|
|
158
|
+
DROP TABLE {self.settings.statistics_table};
|
|
152
159
|
DROP TYPE {self.settings.queue_status_type};
|
|
153
|
-
DROP TYPE {self.settings.
|
|
160
|
+
DROP TYPE {self.settings.statistics_table_status_type};
|
|
154
161
|
"""
|
|
155
162
|
await self.pool.execute(query)
|
|
156
163
|
|
|
@@ -182,7 +189,7 @@ class Queries:
|
|
|
182
189
|
async def enqueue(
|
|
183
190
|
self,
|
|
184
191
|
entrypoint: str | list[str],
|
|
185
|
-
payload: bytes | None | list[bytes | None],
|
|
192
|
+
payload: bytes | None | list[bytes] | list[None] | list[bytes | None],
|
|
186
193
|
priority: int | list[int] = 0,
|
|
187
194
|
) -> None:
|
|
188
195
|
"""
|
|
@@ -231,7 +238,7 @@ class Queries:
|
|
|
231
238
|
query = f"TRUNCATE {self.settings.queue_table}"
|
|
232
239
|
await self.pool.execute(query)
|
|
233
240
|
|
|
234
|
-
async def queue_size(self) -> list[models.
|
|
241
|
+
async def queue_size(self) -> list[models.QueueStatistics]:
|
|
235
242
|
"""
|
|
236
243
|
Returns the number of jobs in the queue grouped by entrypoint and priority.
|
|
237
244
|
"""
|
|
@@ -242,10 +249,11 @@ class Queries:
|
|
|
242
249
|
entrypoint
|
|
243
250
|
FROM
|
|
244
251
|
{self.settings.queue_table}
|
|
245
|
-
GROUP BY
|
|
252
|
+
GROUP BY entrypoint, priority
|
|
253
|
+
ORDER BY count, entrypoint, priority
|
|
246
254
|
"""
|
|
247
255
|
return [
|
|
248
|
-
models.
|
|
256
|
+
models.QueueStatistics.model_validate(dict(x))
|
|
249
257
|
for x in await self.pool.fetch(query)
|
|
250
258
|
]
|
|
251
259
|
|
|
@@ -254,20 +262,56 @@ class Queries:
|
|
|
254
262
|
Moves a completed or failed job from the queue table to the log
|
|
255
263
|
table, recording its final status and duration.
|
|
256
264
|
"""
|
|
265
|
+
|
|
257
266
|
query = f"""
|
|
258
|
-
WITH
|
|
267
|
+
WITH deleted AS (
|
|
259
268
|
DELETE FROM {self.settings.queue_table}
|
|
260
269
|
WHERE id = $1
|
|
261
|
-
RETURNING id, priority, created, entrypoint
|
|
262
270
|
)
|
|
263
271
|
|
|
264
|
-
INSERT INTO {self.settings.
|
|
265
|
-
|
|
272
|
+
INSERT INTO {self.settings.statistics_table} (
|
|
273
|
+
priority,
|
|
274
|
+
entrypoint,
|
|
275
|
+
time_in_queue,
|
|
276
|
+
created,
|
|
277
|
+
status,
|
|
278
|
+
count
|
|
279
|
+
)
|
|
280
|
+
VALUES (
|
|
281
|
+
$2, -- priority
|
|
282
|
+
$3, -- entrypoint
|
|
283
|
+
DATE_TRUNC('sec', NOW() - $4), -- time_in_queue
|
|
284
|
+
DATE_TRUNC('sec', $4 at time zone 'UTC'), -- created at time zone 'UTC'
|
|
285
|
+
$5, -- status
|
|
286
|
+
1 -- start count
|
|
266
287
|
)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
288
|
+
ON CONFLICT (
|
|
289
|
+
priority,
|
|
290
|
+
entrypoint,
|
|
291
|
+
DATE_TRUNC('sec', created at time zone 'UTC'),
|
|
292
|
+
DATE_TRUNC('sec', time_in_queue),
|
|
293
|
+
status
|
|
294
|
+
)
|
|
295
|
+
DO UPDATE
|
|
296
|
+
SET
|
|
297
|
+
count = {self.settings.statistics_table}.count + 1
|
|
298
|
+
WHERE
|
|
299
|
+
{self.settings.statistics_table}.priority = $2
|
|
300
|
+
AND DATE_TRUNC('sec', {self.settings.statistics_table}.time_in_queue) = DATE_TRUNC('sec', NOW() - $4)
|
|
301
|
+
AND DATE_TRUNC('sec', {self.settings.statistics_table}.created) = DATE_TRUNC('sec', $4 at time zone 'UTC')
|
|
302
|
+
AND {self.settings.statistics_table}.status = $5
|
|
303
|
+
AND {self.settings.statistics_table}.entrypoint = $3
|
|
304
|
+
|
|
305
|
+
""" # noqa: E501
|
|
306
|
+
|
|
307
|
+
await self.pool.execute(
|
|
308
|
+
query,
|
|
309
|
+
job.id,
|
|
310
|
+
job.priority,
|
|
311
|
+
job.entrypoint,
|
|
312
|
+
job.created,
|
|
313
|
+
status,
|
|
314
|
+
)
|
|
271
315
|
|
|
272
316
|
async def clear_log(self, entrypoint: str | list[str] | None = None) -> None:
|
|
273
317
|
"""
|
|
@@ -275,29 +319,35 @@ class Queries:
|
|
|
275
319
|
by entrypoint if specified.
|
|
276
320
|
"""
|
|
277
321
|
if entrypoint:
|
|
278
|
-
query = f"
|
|
322
|
+
query = f"""
|
|
323
|
+
DELETE FROM {self.settings.statistics_table}
|
|
324
|
+
WHERE entrypoint = ANY($1)
|
|
325
|
+
"""
|
|
279
326
|
await self.pool.execute(
|
|
280
327
|
query,
|
|
281
328
|
[entrypoint] if isinstance(entrypoint, str) else entrypoint,
|
|
282
329
|
)
|
|
283
330
|
else:
|
|
284
|
-
query = f"TRUNCATE {self.settings.
|
|
331
|
+
query = f"TRUNCATE {self.settings.statistics_table}"
|
|
285
332
|
await self.pool.execute(query)
|
|
286
333
|
|
|
287
|
-
async def
|
|
288
|
-
"""
|
|
289
|
-
Returns the number of log entries grouped by status, entrypoint, and priority.
|
|
290
|
-
"""
|
|
334
|
+
async def log_statistics(self, tail: int) -> list[models.LogStatistics]:
|
|
291
335
|
query = f"""
|
|
292
336
|
SELECT
|
|
293
|
-
count
|
|
294
|
-
|
|
337
|
+
count,
|
|
338
|
+
created,
|
|
339
|
+
time_in_queue,
|
|
340
|
+
entrypoint,
|
|
295
341
|
priority,
|
|
296
|
-
|
|
297
|
-
FROM
|
|
298
|
-
|
|
299
|
-
|
|
342
|
+
status
|
|
343
|
+
FROM {self.settings.statistics_table}
|
|
344
|
+
ORDER BY (
|
|
345
|
+
id, created, count, time_in_queue,
|
|
346
|
+
entrypoint, priority, status
|
|
347
|
+
) DESC
|
|
348
|
+
LIMIT $1
|
|
300
349
|
"""
|
|
301
350
|
return [
|
|
302
|
-
models.
|
|
351
|
+
models.LogStatistics.model_validate(dict(x))
|
|
352
|
+
for x in await self.pool.fetch(query, tail)
|
|
303
353
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: PgQueuer
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: PgQueuer is a Python library leveraging PostgreSQL for efficient job queuing.
|
|
5
5
|
Author: janbjorge
|
|
6
6
|
License: MIT License
|
|
@@ -30,6 +30,7 @@ Requires-Dist: anyio>=4.0
|
|
|
30
30
|
Requires-Dist: asyncpg>=0.27.0
|
|
31
31
|
Requires-Dist: pgcachewatch>=0.4
|
|
32
32
|
Requires-Dist: pydantic>=2.0.0
|
|
33
|
+
Requires-Dist: tabulate>=0.9.0
|
|
33
34
|
Provides-Extra: dev
|
|
34
35
|
Requires-Dist: asyncpg-stubs; extra == "dev"
|
|
35
36
|
Requires-Dist: mypy-extensions; extra == "dev"
|
|
@@ -37,6 +38,7 @@ Requires-Dist: mypy; extra == "dev"
|
|
|
37
38
|
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
38
39
|
Requires-Dist: pytest; extra == "dev"
|
|
39
40
|
Requires-Dist: ruff; extra == "dev"
|
|
41
|
+
Requires-Dist: types-tabulate; extra == "dev"
|
|
40
42
|
|
|
41
43
|
## 🚀 PgQueuer - Building Smoother Workflows One Queue at a Time 🚀
|
|
42
44
|
[](https://github.com/janbjorge/PgQueuer/actions/workflows/ci.yml?query=branch%3Amain)
|
|
@@ -24,8 +24,11 @@ async def test_job_queing(
|
|
|
24
24
|
assert context
|
|
25
25
|
seen.append(int(context.payload))
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
await c.queries.enqueue(
|
|
28
|
+
["fetch"] * N,
|
|
29
|
+
[f"{n}".encode() for n in range(N)],
|
|
30
|
+
[0] * N,
|
|
31
|
+
)
|
|
29
32
|
|
|
30
33
|
# Stop flag
|
|
31
34
|
await c.queries.enqueue("fetch", None)
|
|
@@ -56,8 +59,11 @@ async def test_job_fetch(
|
|
|
56
59
|
assert context
|
|
57
60
|
seen.append(int(context.payload))
|
|
58
61
|
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
await q.enqueue(
|
|
63
|
+
["fetch"] * N,
|
|
64
|
+
[f"{n}".encode() for n in range(N)],
|
|
65
|
+
[0] * N,
|
|
66
|
+
)
|
|
61
67
|
|
|
62
68
|
# Stop flag
|
|
63
69
|
await q.enqueue("fetch", None)
|
|
@@ -92,8 +98,11 @@ async def test_sync_entrypoint(
|
|
|
92
98
|
assert context
|
|
93
99
|
seen.append(int(context.payload))
|
|
94
100
|
|
|
95
|
-
|
|
96
|
-
|
|
101
|
+
await q.enqueue(
|
|
102
|
+
["fetch"] * N,
|
|
103
|
+
[f"{n}".encode() for n in range(N)],
|
|
104
|
+
[0] * N,
|
|
105
|
+
)
|
|
97
106
|
|
|
98
107
|
# Stop flag
|
|
99
108
|
await q.enqueue("fetch", None)
|
|
@@ -24,8 +24,11 @@ async def test_queries_next_jobs(
|
|
|
24
24
|
) -> None:
|
|
25
25
|
q = queries.Queries(pgpool)
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
await q.enqueue(
|
|
28
|
+
["placeholder"] * N,
|
|
29
|
+
[f"{n}".encode() for n in range(N)],
|
|
30
|
+
[0] * N,
|
|
31
|
+
)
|
|
29
32
|
|
|
30
33
|
seen = list[int]()
|
|
31
34
|
while job := await q.dequeue():
|
|
@@ -47,8 +50,11 @@ async def test_queries_next_jobs_concurrent(
|
|
|
47
50
|
assert pgpool.get_max_size() >= concurrency
|
|
48
51
|
q = queries.Queries(pgpool)
|
|
49
52
|
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
await q.enqueue(
|
|
54
|
+
["placeholder"] * N,
|
|
55
|
+
[f"{n}".encode() for n in range(N)],
|
|
56
|
+
[0] * N,
|
|
57
|
+
)
|
|
52
58
|
|
|
53
59
|
seen = list[int]()
|
|
54
60
|
|
|
@@ -86,13 +92,16 @@ async def test_move_job_log(
|
|
|
86
92
|
) -> None:
|
|
87
93
|
q = queries.Queries(pgpool)
|
|
88
94
|
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
await q.enqueue(
|
|
96
|
+
["placeholer"] * N,
|
|
97
|
+
[f"{n}".encode() for n in range(N)],
|
|
98
|
+
[0] * N,
|
|
99
|
+
)
|
|
91
100
|
|
|
92
|
-
while
|
|
93
|
-
await q.log_job(
|
|
101
|
+
while job := await q.dequeue():
|
|
102
|
+
await q.log_job(job, status="successful")
|
|
94
103
|
|
|
95
|
-
assert sum(x.count for x in await q.
|
|
104
|
+
assert sum(x.count for x in await q.log_statistics(1_000_000_000)) == N
|
|
96
105
|
|
|
97
106
|
|
|
98
107
|
@pytest.mark.parametrize("N", (1, 2, 5))
|
|
@@ -103,8 +112,11 @@ async def test_clear_queue(
|
|
|
103
112
|
q = queries.Queries(pgpool)
|
|
104
113
|
|
|
105
114
|
# Test delete all by listing all
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
await q.enqueue(
|
|
116
|
+
[f"placeholer{n}" for n in range(N)],
|
|
117
|
+
[None] * N,
|
|
118
|
+
[0] * N,
|
|
119
|
+
)
|
|
108
120
|
|
|
109
121
|
assert all(x.count == 1 for x in await q.queue_size())
|
|
110
122
|
assert sum(x.count for x in await q.queue_size()) == N
|
|
@@ -112,8 +124,11 @@ async def test_clear_queue(
|
|
|
112
124
|
assert sum(x.count for x in await q.queue_size()) == 0
|
|
113
125
|
|
|
114
126
|
# Test delete all by None
|
|
115
|
-
|
|
116
|
-
|
|
127
|
+
await q.enqueue(
|
|
128
|
+
[f"placeholer{n}" for n in range(N)],
|
|
129
|
+
[None] * N,
|
|
130
|
+
[0] * N,
|
|
131
|
+
)
|
|
117
132
|
|
|
118
133
|
assert all(x.count == 1 for x in await q.queue_size())
|
|
119
134
|
assert sum(x.count for x in await q.queue_size()) == N
|
|
@@ -121,8 +136,11 @@ async def test_clear_queue(
|
|
|
121
136
|
assert sum(x.count for x in await q.queue_size()) == 0
|
|
122
137
|
|
|
123
138
|
# Test delete one(1).
|
|
124
|
-
|
|
125
|
-
|
|
139
|
+
await q.enqueue(
|
|
140
|
+
[f"placeholer{n}" for n in range(N)],
|
|
141
|
+
[None] * N,
|
|
142
|
+
[0] * N,
|
|
143
|
+
)
|
|
126
144
|
|
|
127
145
|
assert all(x.count == 1 for x in await q.queue_size())
|
|
128
146
|
assert sum(x.count for x in await q.queue_size()) == N
|
|
@@ -138,8 +156,11 @@ async def test_queue_priority(
|
|
|
138
156
|
q = queries.Queries(pgpool)
|
|
139
157
|
jobs = list[models.Job]()
|
|
140
158
|
|
|
141
|
-
|
|
142
|
-
|
|
159
|
+
await q.enqueue(
|
|
160
|
+
["placeholder"] * N,
|
|
161
|
+
[f"{n}".encode() for n in range(N)],
|
|
162
|
+
list(range(N)),
|
|
163
|
+
)
|
|
143
164
|
|
|
144
165
|
while next_job := await q.dequeue():
|
|
145
166
|
jobs.append(next_job)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import contextlib
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import Callable, Generator
|
|
8
|
+
|
|
9
|
+
import asyncpg
|
|
10
|
+
from PgQueuer.models import Job
|
|
11
|
+
from PgQueuer.qm import QueueManager
|
|
12
|
+
from PgQueuer.queries import Queries
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@contextlib.contextmanager
|
|
16
|
+
def execution_timer() -> (
|
|
17
|
+
Generator[
|
|
18
|
+
Callable[[], timedelta],
|
|
19
|
+
None,
|
|
20
|
+
None,
|
|
21
|
+
]
|
|
22
|
+
):
|
|
23
|
+
enter = time.perf_counter()
|
|
24
|
+
done: float | None = None
|
|
25
|
+
try:
|
|
26
|
+
yield lambda: timedelta(seconds=((done or time.perf_counter()) - enter))
|
|
27
|
+
finally:
|
|
28
|
+
done = time.perf_counter()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def run_qm(pool: asyncpg.Pool) -> None:
|
|
32
|
+
qm = QueueManager(pool)
|
|
33
|
+
|
|
34
|
+
@qm.entrypoint("async")
|
|
35
|
+
async def asyncfetch(job: Job) -> None:
|
|
36
|
+
if job.payload is None:
|
|
37
|
+
qm.alive = False
|
|
38
|
+
|
|
39
|
+
@qm.entrypoint("sync")
|
|
40
|
+
def syncfetch(job: Job) -> None:
|
|
41
|
+
if job.payload is None:
|
|
42
|
+
qm.alive = False
|
|
43
|
+
|
|
44
|
+
await qm.run(timedelta(seconds=0))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def main() -> None:
|
|
48
|
+
parser = argparse.ArgumentParser(
|
|
49
|
+
description="Run the job processing benchmark.",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"-t",
|
|
53
|
+
"--time-limit",
|
|
54
|
+
type=lambda x: timedelta(seconds=float(x)),
|
|
55
|
+
default=timedelta(seconds=10),
|
|
56
|
+
help="Run the benchmark for a limited number of seconds. Default is 10",
|
|
57
|
+
)
|
|
58
|
+
args = parser.parse_args()
|
|
59
|
+
start = datetime.now()
|
|
60
|
+
|
|
61
|
+
async with asyncpg.create_pool() as pool:
|
|
62
|
+
queries = Queries(pool)
|
|
63
|
+
await queries.clear_log()
|
|
64
|
+
while True:
|
|
65
|
+
for concurrecy in range(1, 6):
|
|
66
|
+
N = concurrecy * 1_000
|
|
67
|
+
|
|
68
|
+
await queries.clear_queue()
|
|
69
|
+
|
|
70
|
+
entrypoints = ["sync"] * (N // 2) + ["async"] * (N // 2)
|
|
71
|
+
random.shuffle(entrypoints)
|
|
72
|
+
payloads = [f"{n}".encode() for n in range(N)]
|
|
73
|
+
priorities = [0] * N
|
|
74
|
+
await queries.enqueue(entrypoints, payloads, priorities)
|
|
75
|
+
await queries.enqueue(
|
|
76
|
+
["async"] * concurrecy**2,
|
|
77
|
+
[None] * concurrecy**2,
|
|
78
|
+
[0] * concurrecy**2,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
with execution_timer() as elapsed:
|
|
82
|
+
await asyncio.gather(
|
|
83
|
+
*[run_qm(pool) for _ in range(concurrecy)],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
elapsed_total_seconds = elapsed().total_seconds()
|
|
87
|
+
jps = (N - len(await queries.queue_size())) / elapsed_total_seconds
|
|
88
|
+
|
|
89
|
+
print(
|
|
90
|
+
f"Concurrecy: {concurrecy:<2} ",
|
|
91
|
+
f"Jobs: {N:<5} ",
|
|
92
|
+
f"JPS: {(jps)/1_000:.1f}k",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if timedelta(seconds=0) < args.time_limit < datetime.now() - start:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
asyncio.run(main())
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import contextlib
|
|
3
|
-
import time
|
|
4
|
-
from datetime import timedelta
|
|
5
|
-
from typing import Callable, Generator
|
|
6
|
-
|
|
7
|
-
import asyncpg
|
|
8
|
-
from PgQueuer.models import Job
|
|
9
|
-
from PgQueuer.qm import QueueManager
|
|
10
|
-
from PgQueuer.queries import Queries
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@contextlib.contextmanager
|
|
14
|
-
def timer() -> (
|
|
15
|
-
Generator[
|
|
16
|
-
Callable[[], float],
|
|
17
|
-
None,
|
|
18
|
-
None,
|
|
19
|
-
]
|
|
20
|
-
):
|
|
21
|
-
enter = time.perf_counter()
|
|
22
|
-
done: float | None = None
|
|
23
|
-
try:
|
|
24
|
-
yield lambda: done - enter if done else time.perf_counter() - enter
|
|
25
|
-
finally:
|
|
26
|
-
done = time.perf_counter()
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
async def jobs_per_second(pool: asyncpg.Pool) -> float:
|
|
30
|
-
qm = QueueManager(pool)
|
|
31
|
-
seen = 0
|
|
32
|
-
|
|
33
|
-
@qm.entrypoint("async")
|
|
34
|
-
async def asyncfetch(job: Job) -> None:
|
|
35
|
-
nonlocal qm, seen
|
|
36
|
-
seen += 1
|
|
37
|
-
if job.payload is None:
|
|
38
|
-
qm.alive = False
|
|
39
|
-
|
|
40
|
-
@qm.entrypoint("sync")
|
|
41
|
-
def syncfetch(job: Job) -> None:
|
|
42
|
-
nonlocal qm, seen
|
|
43
|
-
seen += 1
|
|
44
|
-
if job.payload is None:
|
|
45
|
-
qm.alive = False
|
|
46
|
-
|
|
47
|
-
with timer() as elapsed:
|
|
48
|
-
await qm.run(timedelta(seconds=0))
|
|
49
|
-
|
|
50
|
-
return seen / elapsed()
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
async def benchmark(
|
|
54
|
-
entrypoint: str,
|
|
55
|
-
concurrecy: int,
|
|
56
|
-
N: int,
|
|
57
|
-
pool: asyncpg.Pool,
|
|
58
|
-
) -> None:
|
|
59
|
-
queries = Queries(pool)
|
|
60
|
-
|
|
61
|
-
await queries.clear_log()
|
|
62
|
-
await queries.clear_queue()
|
|
63
|
-
|
|
64
|
-
await queries.enqueue(
|
|
65
|
-
[entrypoint] * N,
|
|
66
|
-
[f"{n}".encode() for n in range(N)],
|
|
67
|
-
[0] * N,
|
|
68
|
-
)
|
|
69
|
-
assert sum(x.count for x in await queries.queue_size()) == N
|
|
70
|
-
|
|
71
|
-
await queries.enqueue(
|
|
72
|
-
[entrypoint] * concurrecy**2,
|
|
73
|
-
[None] * concurrecy**2,
|
|
74
|
-
[0] * concurrecy**2,
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
jobs = [jobs_per_second(pool) for _ in range(concurrecy)]
|
|
78
|
-
results = await asyncio.gather(*jobs)
|
|
79
|
-
|
|
80
|
-
print(
|
|
81
|
-
f"Entrypoint: {entrypoint:<5} ",
|
|
82
|
-
f"Concurrecy: {concurrecy:<2} "
|
|
83
|
-
f"Jobs: {N:<5} "
|
|
84
|
-
f"JPS: {sum(results)/1_000:.1f}k",
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
async def main() -> None:
|
|
89
|
-
async with asyncpg.create_pool(
|
|
90
|
-
min_size=20,
|
|
91
|
-
max_size=99,
|
|
92
|
-
) as pool:
|
|
93
|
-
for entrypoint in ("sync", "async"):
|
|
94
|
-
for concurrecy in range(1, 6):
|
|
95
|
-
await benchmark(entrypoint, concurrecy, 1_000 * concurrecy, pool)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if __name__ == "__main__":
|
|
99
|
-
asyncio.run(main())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|