PgQueuer 0.0.1__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.
Potentially problematic release.
This version of PgQueuer might be problematic. Click here for more details.
- PgQueuer/__init__.py +0 -0
- PgQueuer/__main__.py +6 -0
- PgQueuer/_version.py +17 -0
- PgQueuer/cli.py +130 -0
- PgQueuer/logconfig.py +7 -0
- PgQueuer/models.py +32 -0
- PgQueuer/py.typed +0 -0
- PgQueuer/qm.py +119 -0
- PgQueuer/queries.py +281 -0
- PgQueuer/tm.py +23 -0
- PgQueuer-0.0.1.dist-info/LICENSE +21 -0
- PgQueuer-0.0.1.dist-info/METADATA +140 -0
- PgQueuer-0.0.1.dist-info/RECORD +16 -0
- PgQueuer-0.0.1.dist-info/WHEEL +5 -0
- PgQueuer-0.0.1.dist-info/top_level.txt +2 -0
- _version.py +16 -0
PgQueuer/__init__.py
ADDED
|
File without changes
|
PgQueuer/__main__.py
ADDED
PgQueuer/_version.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# file generated by setuptools_scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
TYPE_CHECKING = False
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from typing import Tuple, Union
|
|
6
|
+
|
|
7
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
8
|
+
else:
|
|
9
|
+
VERSION_TUPLE = object
|
|
10
|
+
|
|
11
|
+
version: str
|
|
12
|
+
__version__: str
|
|
13
|
+
__version_tuple__: VERSION_TUPLE
|
|
14
|
+
version_tuple: VERSION_TUPLE
|
|
15
|
+
|
|
16
|
+
__version__ = version = "0.1.dev1+gac06244.d20240420"
|
|
17
|
+
__version_tuple__ = version_tuple = (0, 1, "dev1", "gac06244.d20240420")
|
PgQueuer/cli.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import asyncpg
|
|
5
|
+
|
|
6
|
+
from PgQueuer import queries
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def cliparser() -> argparse.Namespace:
|
|
10
|
+
common_arguments = argparse.ArgumentParser(
|
|
11
|
+
add_help=False,
|
|
12
|
+
prog="PgQueuer",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
common_arguments.add_argument(
|
|
16
|
+
"--prefix",
|
|
17
|
+
default="",
|
|
18
|
+
help=(
|
|
19
|
+
"All PgQueuer tables/functions/etc... will start with this prefix. "
|
|
20
|
+
"(If set, addinal config is required.)"
|
|
21
|
+
),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
common_arguments.add_argument(
|
|
25
|
+
"--pg-dsn",
|
|
26
|
+
help=(
|
|
27
|
+
"Connection string in the libpq URI format, including host, port, user, "
|
|
28
|
+
"database, password, passfile, and SSL options. Must be properly quoted; "
|
|
29
|
+
"IPv6 addresses must be in brackets. "
|
|
30
|
+
"Example: postgres://user:pass@host:port/database. Defaults to PGDSN "
|
|
31
|
+
"environment variable if set."
|
|
32
|
+
),
|
|
33
|
+
default=os.environ.get("PGDSN"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
common_arguments.add_argument(
|
|
37
|
+
"--pg-host",
|
|
38
|
+
help=(
|
|
39
|
+
"Database host address, which can be an IP or domain name. "
|
|
40
|
+
"Defaults to PGHOST environment variable if set."
|
|
41
|
+
),
|
|
42
|
+
default=os.environ.get("PGHOST"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
common_arguments.add_argument(
|
|
46
|
+
"--pg-port",
|
|
47
|
+
help=(
|
|
48
|
+
"Port number for the server host Defaults to PGPORT environment variable "
|
|
49
|
+
"or 5432 if not set."
|
|
50
|
+
),
|
|
51
|
+
default=os.environ.get("PGPORT", "5432"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
common_arguments.add_argument(
|
|
55
|
+
"--pg-user",
|
|
56
|
+
help=(
|
|
57
|
+
"Database role for authentication. Defaults to PGUSER environment "
|
|
58
|
+
"variable if set."
|
|
59
|
+
),
|
|
60
|
+
default=os.environ.get("PGUSER"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
common_arguments.add_argument(
|
|
64
|
+
"--pg-database",
|
|
65
|
+
help=(
|
|
66
|
+
"Name of the database to connect to. Defaults to PGDATABASE environment "
|
|
67
|
+
"variable if set."
|
|
68
|
+
),
|
|
69
|
+
default=os.environ.get("PGDATABASE"),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
common_arguments.add_argument(
|
|
73
|
+
"--pg-password",
|
|
74
|
+
help=(
|
|
75
|
+
"Password for authentication. Defaults to PGPASSWORD "
|
|
76
|
+
"environment variable if set"
|
|
77
|
+
),
|
|
78
|
+
default=os.environ.get("PGPASSWORD"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
parser = argparse.ArgumentParser(
|
|
82
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
83
|
+
prog="pgcachewatch",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
subparsers = parser.add_subparsers(
|
|
87
|
+
dest="command",
|
|
88
|
+
required=True,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
subparsers.add_parser(
|
|
92
|
+
"install",
|
|
93
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
94
|
+
parents=[common_arguments],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
subparsers.add_parser(
|
|
98
|
+
"uninstall",
|
|
99
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
100
|
+
parents=[common_arguments],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return parser.parse_args()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def main() -> None:
|
|
107
|
+
parsed = cliparser()
|
|
108
|
+
|
|
109
|
+
if (
|
|
110
|
+
"PGQUEUER_PREFIX" not in os.environ
|
|
111
|
+
and isinstance(prefix := parsed.prefix, str)
|
|
112
|
+
and prefix
|
|
113
|
+
):
|
|
114
|
+
os.environ["PGQUEUER_PREFIX"] = prefix
|
|
115
|
+
|
|
116
|
+
async with asyncpg.create_pool(
|
|
117
|
+
parsed.pg_dsn,
|
|
118
|
+
database=parsed.pg_database,
|
|
119
|
+
password=parsed.pg_password,
|
|
120
|
+
port=parsed.pg_port,
|
|
121
|
+
user=parsed.pg_user,
|
|
122
|
+
host=parsed.pg_host,
|
|
123
|
+
min_size=0,
|
|
124
|
+
max_size=1,
|
|
125
|
+
) as pool:
|
|
126
|
+
match parsed.command:
|
|
127
|
+
case "install":
|
|
128
|
+
await queries.InstallUninstallQueries(pool=pool).install()
|
|
129
|
+
case "uninstall":
|
|
130
|
+
await queries.InstallUninstallQueries(pool=pool).uninstall()
|
PgQueuer/logconfig.py
ADDED
PgQueuer/models.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import AwareDatetime, BaseModel, RootModel
|
|
4
|
+
|
|
5
|
+
STATUS = Literal[
|
|
6
|
+
"queued",
|
|
7
|
+
"picked",
|
|
8
|
+
]
|
|
9
|
+
STATUS_LOG = Literal[
|
|
10
|
+
"exception",
|
|
11
|
+
"successful",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Job(BaseModel):
|
|
16
|
+
"""
|
|
17
|
+
Represents a job with attributes such as ID, priority,
|
|
18
|
+
creation time, status, entrypoint, and optional payload.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
id: int
|
|
22
|
+
priority: int
|
|
23
|
+
created: AwareDatetime
|
|
24
|
+
status: STATUS
|
|
25
|
+
entrypoint: str
|
|
26
|
+
payload: bytes | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Jobs(RootModel[list[Job]]):
|
|
30
|
+
"""
|
|
31
|
+
A collection model that encapsulates a list of Job instances.
|
|
32
|
+
"""
|
PgQueuer/py.typed
ADDED
|
File without changes
|
PgQueuer/qm.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import dataclasses
|
|
5
|
+
from typing import Awaitable, Callable, TypeAlias, TypeVar
|
|
6
|
+
|
|
7
|
+
import asyncpg
|
|
8
|
+
from pgcachewatch.listeners import PGEventQueue
|
|
9
|
+
from pgcachewatch.models import PGChannel
|
|
10
|
+
|
|
11
|
+
from .logconfig import logger
|
|
12
|
+
from .models import Job
|
|
13
|
+
from .queries import PgQueuerLogQueries, PgQueuerQueries
|
|
14
|
+
from .tm import TaskManager
|
|
15
|
+
|
|
16
|
+
EntrypointFn: TypeAlias = Callable[[bytes | None], Awaitable[None]]
|
|
17
|
+
T = TypeVar("T", bound=EntrypointFn)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclasses.dataclass
|
|
21
|
+
class QueueManager:
|
|
22
|
+
"""
|
|
23
|
+
Manages job queues and dispatches jobs to registered entry points,
|
|
24
|
+
handling database connections and events.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
pool: asyncpg.Pool
|
|
28
|
+
q: PgQueuerQueries = dataclasses.field(init=False)
|
|
29
|
+
ql: PgQueuerLogQueries = dataclasses.field(init=False)
|
|
30
|
+
|
|
31
|
+
channel: PGChannel = dataclasses.field(
|
|
32
|
+
default=PGChannel("ch_pgqueuer"),
|
|
33
|
+
init=False,
|
|
34
|
+
)
|
|
35
|
+
alive: bool = dataclasses.field(
|
|
36
|
+
init=False,
|
|
37
|
+
default=True,
|
|
38
|
+
)
|
|
39
|
+
# Should registry be a weakref?
|
|
40
|
+
registry: dict[str, EntrypointFn] = dataclasses.field(
|
|
41
|
+
init=False,
|
|
42
|
+
default_factory=dict,
|
|
43
|
+
)
|
|
44
|
+
tm: TaskManager = dataclasses.field(
|
|
45
|
+
init=False,
|
|
46
|
+
default_factory=TaskManager,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Initializes database query handlers and validates pool size upon
|
|
52
|
+
instance creation.
|
|
53
|
+
"""
|
|
54
|
+
if self.pool.get_min_size() < 1:
|
|
55
|
+
raise ValueError("... min size must be gt 1.")
|
|
56
|
+
self.q = PgQueuerQueries(self.pool)
|
|
57
|
+
self.ql = PgQueuerLogQueries(self.pool)
|
|
58
|
+
|
|
59
|
+
def entrypoint(self, name: str) -> Callable[[T], T]:
|
|
60
|
+
"""
|
|
61
|
+
Decorator to register a function as an entrypoint for
|
|
62
|
+
handling specific job types.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def register(func: T) -> T:
|
|
66
|
+
if name in self.registry:
|
|
67
|
+
raise RuntimeError(f"{name} already in registry, must be unique.")
|
|
68
|
+
self.registry[name] = func
|
|
69
|
+
return func
|
|
70
|
+
|
|
71
|
+
return register
|
|
72
|
+
|
|
73
|
+
async def run(self) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Starts the event listener and continuously dispatches jobs to
|
|
76
|
+
registered entry points until stopped.
|
|
77
|
+
"""
|
|
78
|
+
async with self.pool.acquire() as conn:
|
|
79
|
+
listener = PGEventQueue()
|
|
80
|
+
await listener.connect(conn, self.channel)
|
|
81
|
+
|
|
82
|
+
while self.alive:
|
|
83
|
+
while (jobs := await self.q.dequeue()).root:
|
|
84
|
+
for job in jobs.root:
|
|
85
|
+
self._dispatch(job)
|
|
86
|
+
await listener.get()
|
|
87
|
+
|
|
88
|
+
await asyncio.gather(*self.tm.tasks)
|
|
89
|
+
|
|
90
|
+
def _dispatch(self, job: Job) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Internal method to asynchronously handle job dispatch,
|
|
93
|
+
including exception logging and job status updates.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
async def runit() -> None:
|
|
97
|
+
logger.debug(
|
|
98
|
+
"Dispatching entrypoint/id: %s/%s",
|
|
99
|
+
job.entrypoint,
|
|
100
|
+
job.id,
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
await self.registry[job.entrypoint](job.payload)
|
|
104
|
+
except Exception:
|
|
105
|
+
logger.exception(
|
|
106
|
+
"Exception while processing entrypoint/id: %s/%s",
|
|
107
|
+
job.entrypoint,
|
|
108
|
+
job.id,
|
|
109
|
+
)
|
|
110
|
+
await self.ql.move_job_log(job, "exception")
|
|
111
|
+
else:
|
|
112
|
+
logger.debug(
|
|
113
|
+
"Dispatching entrypoint/id: %s/%s - successful",
|
|
114
|
+
job.entrypoint,
|
|
115
|
+
job.id,
|
|
116
|
+
)
|
|
117
|
+
await self.ql.move_job_log(job, "successful")
|
|
118
|
+
|
|
119
|
+
self.tm.add(asyncio.create_task(runit()))
|
PgQueuer/queries.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import os
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
import asyncpg
|
|
6
|
+
|
|
7
|
+
from . import models
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_prefix(string: str) -> str:
|
|
11
|
+
"""
|
|
12
|
+
Appends a predefined prefix from environment variables to
|
|
13
|
+
the given string, typically used for database object names.
|
|
14
|
+
"""
|
|
15
|
+
return f"{os.environ.get('PGQUEUER_PREFIX', '')}{string}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclasses.dataclass
|
|
19
|
+
class DBSettings:
|
|
20
|
+
"""
|
|
21
|
+
Stores database settings such as table names and SQL
|
|
22
|
+
function names, dynamically appending prefixes from
|
|
23
|
+
environment variables.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
channel: Final[str] = dataclasses.field(
|
|
27
|
+
default_factory=lambda: add_prefix("ch_pgqueuer"),
|
|
28
|
+
kw_only=True,
|
|
29
|
+
)
|
|
30
|
+
function: Final[str] = dataclasses.field(
|
|
31
|
+
default_factory=lambda: add_prefix("fn_pgqueuer_changed"),
|
|
32
|
+
kw_only=True,
|
|
33
|
+
)
|
|
34
|
+
log_table: Final[str] = dataclasses.field(
|
|
35
|
+
default_factory=lambda: add_prefix("pgqueuer_log"),
|
|
36
|
+
kw_only=True,
|
|
37
|
+
)
|
|
38
|
+
log_table_status_type: Final[str] = dataclasses.field(
|
|
39
|
+
default_factory=lambda: add_prefix("pgqueuer_status_log"),
|
|
40
|
+
kw_only=True,
|
|
41
|
+
)
|
|
42
|
+
queue_status_type: Final[str] = dataclasses.field(
|
|
43
|
+
default_factory=lambda: add_prefix("pgqueuer_status"),
|
|
44
|
+
kw_only=True,
|
|
45
|
+
)
|
|
46
|
+
queue_table: Final[str] = dataclasses.field(
|
|
47
|
+
default_factory=lambda: add_prefix("pgqueuer"),
|
|
48
|
+
kw_only=True,
|
|
49
|
+
)
|
|
50
|
+
trigger: Final[str] = dataclasses.field(
|
|
51
|
+
default_factory=lambda: add_prefix("tg_pgqueuer_changed"),
|
|
52
|
+
kw_only=True,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclasses.dataclass
|
|
57
|
+
class InstallUninstallQueries:
|
|
58
|
+
"""
|
|
59
|
+
Provides methods to install (create) and uninstall (drop)
|
|
60
|
+
database schemas and objects like tables, types, and triggers
|
|
61
|
+
related to the pgqueuer system.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
pool: asyncpg.Pool
|
|
65
|
+
settings: DBSettings = dataclasses.field(default_factory=DBSettings)
|
|
66
|
+
|
|
67
|
+
async def install(self) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Creates necessary database structures such as enums,
|
|
70
|
+
tables, and triggers for job queuing and logging.
|
|
71
|
+
"""
|
|
72
|
+
await self.pool.execute(
|
|
73
|
+
f"""
|
|
74
|
+
CREATE TYPE {self.settings.queue_status_type} AS ENUM ('queued', 'picked');
|
|
75
|
+
CREATE TABLE {self.settings.queue_table} (
|
|
76
|
+
id SERIAL PRIMARY KEY,
|
|
77
|
+
priority INT NOT NULL,
|
|
78
|
+
created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
79
|
+
status {self.settings.queue_status_type} NOT NULL,
|
|
80
|
+
entrypoint TEXT NOT NULL,
|
|
81
|
+
payload BYTEA
|
|
82
|
+
);
|
|
83
|
+
CREATE UNIQUE INDEX ON {self.settings.queue_table} (priority, id) WHERE status != 'picked';
|
|
84
|
+
|
|
85
|
+
CREATE TYPE {self.settings.log_table_status_type} AS ENUM ('exception', 'successful');
|
|
86
|
+
CREATE TABLE {self.settings.log_table} (
|
|
87
|
+
id SERIAL PRIMARY KEY,
|
|
88
|
+
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,
|
|
92
|
+
entrypoint TEXT NOT NULL
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
CREATE FUNCTION {self.settings.function}() RETURNS TRIGGER AS $$
|
|
97
|
+
BEGIN
|
|
98
|
+
PERFORM pg_notify(
|
|
99
|
+
'{self.settings.channel}',
|
|
100
|
+
json_build_object(
|
|
101
|
+
'channel', '{self.settings.channel}',
|
|
102
|
+
'operation', lower(TG_OP),
|
|
103
|
+
'sent_at', NOW(),
|
|
104
|
+
'table', TG_TABLE_NAME
|
|
105
|
+
)::text);
|
|
106
|
+
RETURN NEW;
|
|
107
|
+
END;
|
|
108
|
+
$$ LANGUAGE plpgsql;
|
|
109
|
+
|
|
110
|
+
CREATE TRIGGER {self.settings.trigger}
|
|
111
|
+
AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON {self.settings.queue_table}
|
|
112
|
+
EXECUTE FUNCTION {self.settings.function}();
|
|
113
|
+
""" # noqa: E501
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def uninstall(self) -> None:
|
|
117
|
+
"""
|
|
118
|
+
Drops all database structures related to job queuing
|
|
119
|
+
and logging that were created by the install method.
|
|
120
|
+
"""
|
|
121
|
+
await self.pool.execute(
|
|
122
|
+
f"""
|
|
123
|
+
DROP TRIGGER {self.settings.trigger};
|
|
124
|
+
DROP FUNCTION {self.settings.function};
|
|
125
|
+
DROP TABLE {self.settings.queue_table};
|
|
126
|
+
DROP TABLE {self.settings.log_table};
|
|
127
|
+
DROP TYPE {self.settings.queue_status_type};
|
|
128
|
+
DROP TYPE {self.settings.log_table_status_type};
|
|
129
|
+
"""
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclasses.dataclass
|
|
134
|
+
class PgQueuerQueries:
|
|
135
|
+
"""
|
|
136
|
+
Handles operations related to job queuing such as
|
|
137
|
+
enqueueing, dequeueing, and querying the size of the queue.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
pool: asyncpg.Pool
|
|
141
|
+
settings: DBSettings = dataclasses.field(default_factory=DBSettings)
|
|
142
|
+
|
|
143
|
+
async def dequeue(self) -> models.Jobs:
|
|
144
|
+
"""
|
|
145
|
+
Retrieves and updates the next 'queued' job to 'picked'
|
|
146
|
+
status, ensuring no two jobs with the same entrypoint
|
|
147
|
+
are picked simultaneously.
|
|
148
|
+
"""
|
|
149
|
+
query = f"""
|
|
150
|
+
WITH next_job AS (
|
|
151
|
+
SELECT p1.id
|
|
152
|
+
FROM {self.settings.queue_table} p1
|
|
153
|
+
WHERE p1.status = 'queued' AND NOT EXISTS (
|
|
154
|
+
SELECT FROM
|
|
155
|
+
{self.settings.queue_table} p2
|
|
156
|
+
WHERE p1.entrypoint = p2.entrypoint AND p2.status = 'picked')
|
|
157
|
+
ORDER BY id
|
|
158
|
+
FOR UPDATE SKIP LOCKED
|
|
159
|
+
LIMIT 1
|
|
160
|
+
)
|
|
161
|
+
UPDATE {self.settings.queue_table}
|
|
162
|
+
SET status = 'picked'
|
|
163
|
+
WHERE id = ANY(SELECT id FROM next_job)
|
|
164
|
+
RETURNING *;
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
return models.Jobs.model_validate(map(dict, await self.pool.fetch(query)))
|
|
168
|
+
|
|
169
|
+
async def enqueue(
|
|
170
|
+
self,
|
|
171
|
+
entrypoint: str,
|
|
172
|
+
payload: bytes | None,
|
|
173
|
+
priority: int = 0,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Inserts a new job into the queue with the specified
|
|
177
|
+
entrypoint, payload, and priority, marking it as 'queued'.
|
|
178
|
+
"""
|
|
179
|
+
await self.pool.execute(
|
|
180
|
+
f"""
|
|
181
|
+
INSERT INTO {self.settings.queue_table} (priority, status, entrypoint, payload)
|
|
182
|
+
VALUES ($1, 'queued', $2, $3)""",
|
|
183
|
+
priority,
|
|
184
|
+
entrypoint,
|
|
185
|
+
payload,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
async def clear(self, entrypoint: str | list[str] | None = None) -> None:
|
|
189
|
+
"""
|
|
190
|
+
Clears jobs from the queue, optionally filtering by entrypoint if specified.
|
|
191
|
+
"""
|
|
192
|
+
if entrypoint:
|
|
193
|
+
await self.pool.execute(
|
|
194
|
+
f"DELETE FROM {self.settings.queue_table} WHERE entrypoint = ANY($1)",
|
|
195
|
+
[entrypoint] if isinstance(entrypoint, str) else entrypoint,
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
await self.pool.execute(f"TRUNCATE {self.settings.queue_table}")
|
|
199
|
+
|
|
200
|
+
async def qsize(self) -> dict[tuple[str, int], int]:
|
|
201
|
+
"""
|
|
202
|
+
Returns the number of jobs in the queue grouped by entrypoint and priority.
|
|
203
|
+
"""
|
|
204
|
+
return {
|
|
205
|
+
(row["entrypoint"], row["priority"]): row["cnt"]
|
|
206
|
+
for row in await self.pool.fetch(f"""
|
|
207
|
+
SELECT
|
|
208
|
+
count(*) AS cnt,
|
|
209
|
+
priority,
|
|
210
|
+
entrypoint
|
|
211
|
+
FROM
|
|
212
|
+
{self.settings.queue_table}
|
|
213
|
+
GROUP BY priority, entrypoint
|
|
214
|
+
""")
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclasses.dataclass
|
|
219
|
+
class PgQueuerLogQueries:
|
|
220
|
+
"""
|
|
221
|
+
Manages operations related to job logging,
|
|
222
|
+
including moving jobs to a log table, clearing logs,
|
|
223
|
+
and querying the size of log entries.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
pool: asyncpg.Pool
|
|
227
|
+
settings: DBSettings = dataclasses.field(default_factory=DBSettings)
|
|
228
|
+
|
|
229
|
+
async def move_job_log(self, job: models.Job, status: models.STATUS_LOG) -> None:
|
|
230
|
+
"""
|
|
231
|
+
Moves a completed or failed job from the queue table to the log
|
|
232
|
+
table, recording its final status and duration.
|
|
233
|
+
"""
|
|
234
|
+
await self.pool.execute(
|
|
235
|
+
f"""
|
|
236
|
+
WITH moved_row AS (
|
|
237
|
+
DELETE FROM {self.settings.queue_table}
|
|
238
|
+
WHERE id = $1
|
|
239
|
+
RETURNING id, priority, created, entrypoint
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
INSERT INTO {self.settings.log_table} (
|
|
243
|
+
id, priority, created, duration, status, entrypoint
|
|
244
|
+
)
|
|
245
|
+
SELECT id, priority, created, NOW() - created, $2, entrypoint
|
|
246
|
+
FROM moved_row;
|
|
247
|
+
""",
|
|
248
|
+
job.id,
|
|
249
|
+
status,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
async def clear(self, entrypoint: str | list[str] | None = None) -> None:
|
|
253
|
+
"""
|
|
254
|
+
Clears entries from the job log table, optionally filtering
|
|
255
|
+
by entrypoint if specified.
|
|
256
|
+
"""
|
|
257
|
+
if entrypoint:
|
|
258
|
+
await self.pool.execute(
|
|
259
|
+
f"DELETE FROM {self.settings.log_table} WHERE entrypoint = ANY($1)",
|
|
260
|
+
[entrypoint] if isinstance(entrypoint, str) else entrypoint,
|
|
261
|
+
)
|
|
262
|
+
else:
|
|
263
|
+
await self.pool.execute(f"TRUNCATE {self.settings.log_table}")
|
|
264
|
+
|
|
265
|
+
async def qsize(self) -> dict[tuple[str, str, int], int]:
|
|
266
|
+
"""
|
|
267
|
+
Returns the number of log entries grouped by status, entrypoint, and priority.
|
|
268
|
+
"""
|
|
269
|
+
return {
|
|
270
|
+
(row["status"], row["entrypoint"], row["priority"]): row["cnt"]
|
|
271
|
+
for row in await self.pool.fetch(f"""
|
|
272
|
+
SELECT
|
|
273
|
+
count(*) AS cnt,
|
|
274
|
+
status,
|
|
275
|
+
priority,
|
|
276
|
+
entrypoint
|
|
277
|
+
FROM
|
|
278
|
+
{self.settings.log_table}
|
|
279
|
+
GROUP BY status, priority, entrypoint
|
|
280
|
+
""")
|
|
281
|
+
}
|
PgQueuer/tm.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import dataclasses
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclasses.dataclass
|
|
6
|
+
class TaskManager:
|
|
7
|
+
"""
|
|
8
|
+
Manages a collection of asyncio Tasks, keeping track of active
|
|
9
|
+
tasks and removing them once they are complete.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
tasks: set[asyncio.Task] = dataclasses.field(
|
|
13
|
+
default_factory=set,
|
|
14
|
+
init=False,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def add(self, task: asyncio.Task) -> None:
|
|
18
|
+
"""
|
|
19
|
+
Adds an asyncio Task to the manager and registers a
|
|
20
|
+
callback to automatically remove the task when it's done.
|
|
21
|
+
"""
|
|
22
|
+
self.tasks.add(task)
|
|
23
|
+
task.add_done_callback(self.tasks.remove)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 janbjorge
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: PgQueuer
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: PgQueuer is a Python library leveraging PostgreSQL for efficient job queuing.
|
|
5
|
+
Author: janbjorge
|
|
6
|
+
License: MIT License
|
|
7
|
+
Project-URL: Documentation, https://github.com/janbjorge/PgQueuer/
|
|
8
|
+
Project-URL: Homepage, https://github.com/janbjorge/PgQueuer/
|
|
9
|
+
Project-URL: Issues, https://github.com/janbjorge/PgQueuer/issues
|
|
10
|
+
Project-URL: Repository, https://github.com/janbjorge/PgQueuer/
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Web Environment
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Natural Language :: English
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python
|
|
22
|
+
Classifier: Topic :: Database
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: asyncpg >=0.27.0
|
|
30
|
+
Requires-Dist: pgcachewatch >=0.4
|
|
31
|
+
Requires-Dist: pydantic >=2.0.0
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: asyncpg-stubs ; extra == 'dev'
|
|
34
|
+
Requires-Dist: mypy-extensions ; extra == 'dev'
|
|
35
|
+
Requires-Dist: mypy ; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest-asyncio ; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest ; extra == 'dev'
|
|
38
|
+
Requires-Dist: ruff ; extra == 'dev'
|
|
39
|
+
Provides-Extra: docs
|
|
40
|
+
Requires-Dist: myst-parser ; extra == 'docs'
|
|
41
|
+
Requires-Dist: sphinx ; extra == 'docs'
|
|
42
|
+
Requires-Dist: sphinx-rtd-theme ; extra == 'docs'
|
|
43
|
+
|
|
44
|
+
## PgQueuer
|
|
45
|
+
|
|
46
|
+
PgQueuer is a minimalist, high-performance job queue library for Python, leveraging the robustness of PostgreSQL. Designed for simplicity and efficiency, PgQueuer uses PostgreSQL's native features like transactions, row locking, and LISTEN/NOTIFY to manage job queues effortlessly.
|
|
47
|
+
|
|
48
|
+
### Features
|
|
49
|
+
|
|
50
|
+
- **Simple Integration**: Easy to integrate with existing Python applications using PostgreSQL.
|
|
51
|
+
- **Efficient Concurrency Handling**: Utilizes PostgreSQL's `FOR UPDATE SKIP LOCKED` for reliable and concurrent job processing.
|
|
52
|
+
- **Real-time Notifications**: Leverages `LISTEN` and `NOTIFY` for real-time updates on job status changes.
|
|
53
|
+
- **Scalable**: Built to efficiently scale with your application's needs.
|
|
54
|
+
|
|
55
|
+
### Getting Started
|
|
56
|
+
|
|
57
|
+
#### Installation
|
|
58
|
+
|
|
59
|
+
To install PgQueuer, simply run the following command:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install PgQueuer
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
This command installs PgQueuer along with its dependencies, setting up everything you need to start integrating it into your application.
|
|
66
|
+
|
|
67
|
+
#### Database Configuration
|
|
68
|
+
|
|
69
|
+
Configure your database connection settings by setting the appropriate environment variables for your PostgreSQL credentials:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
export PGHOST='localhost'
|
|
73
|
+
export PGDATABASE='your_database'
|
|
74
|
+
export PGUSER='your_username'
|
|
75
|
+
export PGPASSWORD='your_password'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
#### CLI Usage
|
|
79
|
+
|
|
80
|
+
PgQueuer provides a command-line interface for easy management of installation and uninstallation. Ensure you have configured your environment variables or use the appropriate flags to specify your database credentials.
|
|
81
|
+
|
|
82
|
+
- **Installing PgQueuer Database Components**:
|
|
83
|
+
```bash
|
|
84
|
+
python -m PgQueuer install
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- **Uninstalling PgQueuer Database Components**:
|
|
88
|
+
```bash
|
|
89
|
+
python -m uninstall
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The CLI supports several flags to customize the connection settings. Use `--help` to see all available options.
|
|
93
|
+
|
|
94
|
+
### Use-Case: Processing Incoming Data Messages
|
|
95
|
+
|
|
96
|
+
In this scenario, the system is designed to manage a queue of incoming data messages that need to be processed. Each message is encapsulated as a job, and these jobs are then processed by a designated function linked to an entrypoint. This is typical in systems where large volumes of data are collected continuously, such as telemetry data from IoT devices, logs from web servers, or transaction data in financial systems.
|
|
97
|
+
|
|
98
|
+
#### Function Description:
|
|
99
|
+
The `fetch` function, tied to the `fetch` entrypoint, is responsible for processing each message. It simulates a processing task by printing the content of each message. This function could be adapted to perform more complex operations such as parsing, analyzing, storing, or forwarding the data as required by the application.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import asyncio
|
|
103
|
+
import time
|
|
104
|
+
|
|
105
|
+
import asyncpg
|
|
106
|
+
|
|
107
|
+
from PgQueuer import qm, queries
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def main() -> None:
|
|
111
|
+
# Set up a connection pool with a minimum of 2 connections.
|
|
112
|
+
pool = await asyncpg.create_pool(min_size=2)
|
|
113
|
+
# Initialize the QueueManager with the connection pool and query handler.
|
|
114
|
+
app = qm.QueueManager(
|
|
115
|
+
pool,
|
|
116
|
+
queries.PgQueuerQueries(pool),
|
|
117
|
+
queries.PgQueuerLogQueries(pool),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Number of messages to simulate.
|
|
121
|
+
N = 10_000
|
|
122
|
+
|
|
123
|
+
# Enqueue messages.
|
|
124
|
+
for n in range(N):
|
|
125
|
+
await app.q.enqueue("fetch", f"this is from me: {n}".encode())
|
|
126
|
+
|
|
127
|
+
# Define a processing function for each message.
|
|
128
|
+
@c.entrypoint("fetch")
|
|
129
|
+
async def process_message(context: bytes) -> None:
|
|
130
|
+
# Simulate parsing the message.
|
|
131
|
+
message = context.decode()
|
|
132
|
+
# Print the message to simulate processing.
|
|
133
|
+
print(f"Processed message: {message}")
|
|
134
|
+
|
|
135
|
+
await app.run()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
asyncio.run(main())
|
|
140
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
_version.py,sha256=pMnmqZnpVmaqR5nqHztNWzbbtb1oy5bPN_v7uhOH8K8,411
|
|
2
|
+
PgQueuer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
PgQueuer/__main__.py,sha256=Dww9T08Xg8kdRFEvHpXpTA2trY7G-lhafwZf1IUqm6c,97
|
|
4
|
+
PgQueuer/_version.py,sha256=YonpGHgj9UzI33RW5RhfA9spxQnBoSxMf0rWyg16ezg,461
|
|
5
|
+
PgQueuer/cli.py,sha256=kri8GI_Zynn93VP7eRcTx2Kzsxze5SWIQoFIT2mkkbc,3529
|
|
6
|
+
PgQueuer/logconfig.py,sha256=nCfcp663CduoBSqcWjZ6nWRkDJDBC-c98erj0hGCvZc,185
|
|
7
|
+
PgQueuer/models.py,sha256=e4r_3RU1soAD-HgFSn-sWeBLqI50cSi4h-4lo-VUetY,594
|
|
8
|
+
PgQueuer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
PgQueuer/qm.py,sha256=hys7igBkWDQbW2q41q_9Ye_jVJ0-z4OmyvKc31CHzFc,3659
|
|
10
|
+
PgQueuer/queries.py,sha256=Qovzi_zJXeCEFRMvAuAByEkGJ7CALendcepdcYWT5E4,8967
|
|
11
|
+
PgQueuer/tm.py,sha256=t6qU_-pv94D5XeDXtME5O85JRzS3BnaLTZqg57YED0k,596
|
|
12
|
+
PgQueuer-0.0.1.dist-info/LICENSE,sha256=0lCGUd5Pke2Up1Kqn1kRnHcwLlBz60E8xvR7WL4k_kE,1066
|
|
13
|
+
PgQueuer-0.0.1.dist-info/METADATA,sha256=EgHpTY4L0oJuzKIBM-c4ZrP4LbOdgfJSgYXY7SdM-4E,5376
|
|
14
|
+
PgQueuer-0.0.1.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
|
15
|
+
PgQueuer-0.0.1.dist-info/top_level.txt,sha256=7mMxIeRADcMrOwkYdxmOC5S5aydHM7PRnauwqqHHCtk,18
|
|
16
|
+
PgQueuer-0.0.1.dist-info/RECORD,,
|
_version.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# file generated by setuptools_scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
TYPE_CHECKING = False
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from typing import Tuple, Union
|
|
6
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
7
|
+
else:
|
|
8
|
+
VERSION_TUPLE = object
|
|
9
|
+
|
|
10
|
+
version: str
|
|
11
|
+
__version__: str
|
|
12
|
+
__version_tuple__: VERSION_TUPLE
|
|
13
|
+
version_tuple: VERSION_TUPLE
|
|
14
|
+
|
|
15
|
+
__version__ = version = '0.0.1'
|
|
16
|
+
__version_tuple__ = version_tuple = (0, 0, 1)
|