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.
Files changed (35) hide show
  1. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/PKG-INFO +3 -1
  2. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/pyproject.toml +2 -0
  3. pgqueuer-0.2.4/src/PgQueuer/__main__.py +8 -0
  4. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/cli.py +92 -3
  5. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/models.py +6 -3
  6. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/qm.py +23 -17
  7. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/queries.py +88 -38
  8. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer.egg-info/PKG-INFO +3 -1
  9. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer.egg-info/requires.txt +2 -0
  10. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/_version.py +2 -2
  11. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/test/test_qm.py +15 -6
  12. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/test/test_queries.py +38 -17
  13. pgqueuer-0.2.4/tools/benchmark.py +100 -0
  14. pgqueuer-0.2.2/src/PgQueuer/__main__.py +0 -6
  15. pgqueuer-0.2.2/tools/benchmark.py +0 -99
  16. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/.github/workflows/ci.yml +0 -0
  17. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/.github/workflows/linting.yml +0 -0
  18. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/.github/workflows/release.yml +0 -0
  19. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/.gitignore +0 -0
  20. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/LICENSE +0 -0
  21. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/README.md +0 -0
  22. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/docker-compose.yml +0 -0
  23. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/setup.cfg +0 -0
  24. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/__init__.py +0 -0
  25. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/_version.py +0 -0
  26. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/logconfig.py +0 -0
  27. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/py.typed +0 -0
  28. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer/tm.py +0 -0
  29. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer.egg-info/SOURCES.txt +0 -0
  30. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer.egg-info/dependency_links.txt +0 -0
  31. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/src/PgQueuer.egg-info/top_level.txt +0 -0
  32. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/test/conftest.py +0 -0
  33. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/test/db/Dockerfile +0 -0
  34. {pgqueuer-0.2.2 → pgqueuer-0.2.4}/test/db/init_db.sh +0 -0
  35. {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.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
  [![CI](https://github.com/janbjorge/PgQueuer/actions/workflows/ci.yml/badge.svg)](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]
@@ -0,0 +1,8 @@
1
+ import asyncio
2
+ import contextlib
3
+
4
+ from PgQueuer import cli
5
+
6
+ if __name__ == "__main__":
7
+ with contextlib.suppress(KeyboardInterrupt):
8
+ asyncio.run(cli.main())
@@ -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
- from PgQueuer import queries
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.Queries(pool=pool).install()
208
+ await queries.install()
127
209
  case "uninstall":
128
- await queries.Queries(pool=pool).uninstall()
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 QueueSize(BaseModel):
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 LogSize(BaseModel):
40
+ class LogStatistics(BaseModel):
40
41
  """
41
- Represents log details for jobs based on status, entrypoint, and priority.
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
- # Inspired by:
45
- # https://github.com/encode/starlette/blob/9f16bf5c25e126200701f6e04330864f4a91a898/starlette/_utils.py#L38
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
- default=PGChannel(DBSettings().channel),
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
- Decorator to register a function as an entrypoint for
88
- handling specific job types.
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
- Starts the event listener and continuously dispatches jobs to
106
- registered entry points until stopped.
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
- Internal method to asynchronously handle job dispatch,
135
- including exception logging and job status updates.
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
- log_table: Final[str] = dataclasses.field(
35
- default_factory=lambda: add_prefix("pgqueuer_log"),
36
- kw_only=True,
34
+ statistics_table: Final[str] = dataclasses.field(
35
+ default_factory=lambda: add_prefix("pgqueuer_statistics"),
37
36
  )
38
- log_table_status_type: Final[str] = dataclasses.field(
39
- default_factory=lambda: add_prefix("pgqueuer_status_log"),
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 NOT NULL DEFAULT CURRENT_TIMESTAMP,
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.log_table_status_type} AS ENUM ('exception', 'successful');
86
- CREATE TABLE {self.settings.log_table} (
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
- created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
90
- duration INTERVAL NOT NULL,
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.log_table};
158
+ DROP TABLE {self.settings.statistics_table};
152
159
  DROP TYPE {self.settings.queue_status_type};
153
- DROP TYPE {self.settings.log_table_status_type};
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.QueueSize]:
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 priority, entrypoint
252
+ GROUP BY entrypoint, priority
253
+ ORDER BY count, entrypoint, priority
246
254
  """
247
255
  return [
248
- models.QueueSize.model_validate(dict(x))
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 moved_row AS (
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.log_table} (
265
- id, priority, created, duration, status, entrypoint
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
- SELECT id, priority, created, NOW() - created, $2, entrypoint
268
- FROM moved_row;
269
- """
270
- await self.pool.execute(query, job.id, status)
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"DELETE FROM {self.settings.log_table} WHERE entrypoint = ANY($1)"
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.log_table}"
331
+ query = f"TRUNCATE {self.settings.statistics_table}"
285
332
  await self.pool.execute(query)
286
333
 
287
- async def log_size(self) -> list[models.LogSize]:
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(*) AS count,
294
- status,
337
+ count,
338
+ created,
339
+ time_in_queue,
340
+ entrypoint,
295
341
  priority,
296
- entrypoint
297
- FROM
298
- {self.settings.log_table}
299
- GROUP BY status, priority, entrypoint
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.LogSize.model_validate(dict(x)) for x in await self.pool.fetch(query)
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.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
  [![CI](https://github.com/janbjorge/PgQueuer/actions/workflows/ci.yml/badge.svg)](https://github.com/janbjorge/PgQueuer/actions/workflows/ci.yml?query=branch%3Amain)
@@ -2,6 +2,7 @@ anyio>=4.0
2
2
  asyncpg>=0.27.0
3
3
  pgcachewatch>=0.4
4
4
  pydantic>=2.0.0
5
+ tabulate>=0.9.0
5
6
 
6
7
  [dev]
7
8
  asyncpg-stubs
@@ -10,3 +11,4 @@ mypy
10
11
  pytest-asyncio
11
12
  pytest
12
13
  ruff
14
+ types-tabulate
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.2.2'
16
- __version_tuple__ = version_tuple = (0, 2, 2)
15
+ __version__ = version = '0.2.4'
16
+ __version_tuple__ = version_tuple = (0, 2, 4)
@@ -24,8 +24,11 @@ async def test_job_queing(
24
24
  assert context
25
25
  seen.append(int(context.payload))
26
26
 
27
- for n in range(N):
28
- await c.queries.enqueue("fetch", f"{n}".encode())
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
- for n in range(N):
60
- await q.enqueue("fetch", f"{n}".encode())
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
- for n in range(N):
96
- await q.enqueue("fetch", f"{n}".encode())
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
- for n in range(N):
28
- await q.enqueue("placeholer", f"{n}".encode())
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
- for n in range(N):
51
- await q.enqueue("placeholer", f"{n}".encode())
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
- for n in range(N):
90
- await q.enqueue("placeholer", f"{n}".encode())
95
+ await q.enqueue(
96
+ ["placeholer"] * N,
97
+ [f"{n}".encode() for n in range(N)],
98
+ [0] * N,
99
+ )
91
100
 
92
- while next_job := await q.dequeue():
93
- await q.log_job(next_job, status="successful")
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.log_size()) == N
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
- for n in range(N):
107
- await q.enqueue(f"placeholer{n}", None)
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
- for n in range(N):
116
- await q.enqueue(f"placeholer{n}", None)
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
- for n in range(N):
125
- await q.enqueue(f"placeholer{n}", None)
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
- for n in range(N):
142
- await q.enqueue("placeholer", f"{n}".encode(), priority=n)
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,6 +0,0 @@
1
- import asyncio
2
-
3
- from PgQueuer import cli
4
-
5
- if __name__ == "__main__":
6
- asyncio.run(cli.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