pgsync 4.0.0__tar.gz → 4.1.0__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.
- {pgsync-4.0.0 → pgsync-4.1.0}/PKG-INFO +21 -20
- {pgsync-4.0.0 → pgsync-4.1.0}/bin/bootstrap +15 -4
- {pgsync-4.0.0 → pgsync-4.1.0}/bin/parallel_sync +3 -3
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/__init__.py +1 -1
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/base.py +234 -82
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/redisqueue.py +16 -1
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/settings.py +25 -3
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/sync.py +324 -247
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/urls.py +34 -15
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/utils.py +264 -246
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/view.py +6 -4
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync.egg-info/PKG-INFO +21 -20
- pgsync-4.1.0/pgsync.egg-info/requires.txt +30 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_sync.py +204 -74
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_utils.py +3 -17
- pgsync-4.0.0/pgsync.egg-info/requires.txt +0 -30
- {pgsync-4.0.0 → pgsync-4.1.0}/AUTHORS.rst +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/CONTRIBUTING.rst +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/HISTORY.rst +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/LICENSE +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/MANIFEST.in +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/README.md +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/README.rst +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/bin/pgsync +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/docs/Makefile +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/docs/authors.rst +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/docs/changelog.rst +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/docs/conf.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/docs/contributing.rst +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/docs/history.rst +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/docs/index.rst +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/docs/installation.rst +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/docs/logo.png +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/docs/make.bat +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/docs/readme.rst +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/docs/usage.rst +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/constants.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/exc.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/helper.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/node.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/plugin.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/querybuilder.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/search_client.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/singleton.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/transform.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/trigger.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync.egg-info/SOURCES.txt +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync.egg-info/dependency_links.txt +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync.egg-info/not-zip-safe +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/pgsync.egg-info/top_level.txt +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/pyproject.toml +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/setup.cfg +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/setup.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/__init__.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/conftest.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/fixtures/schema.json +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_base.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_constants.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_env_vars.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_helper.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_log_handlers.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_node.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_query_builder.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_redisqueue.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_search_client.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_settings.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_sync_nested_children.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_sync_root.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_sync_single_child_fk_on_child.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_sync_single_child_fk_on_parent.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_transform.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_trigger.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_unique_behaviour.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_urls.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_view.py +0 -0
- {pgsync-4.0.0 → pgsync-4.1.0}/tests/testing_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pgsync
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.1.0
|
|
4
4
|
Summary: Postgres to Elasticsearch/OpenSearch sync
|
|
5
5
|
Home-page: https://github.com/toluaina/pgsync
|
|
6
6
|
Author: Tolu Aina
|
|
@@ -31,34 +31,34 @@ Description-Content-Type: text/markdown
|
|
|
31
31
|
License-File: LICENSE
|
|
32
32
|
License-File: AUTHORS.rst
|
|
33
33
|
Requires-Dist: async-timeout==5.0.1
|
|
34
|
-
Requires-Dist:
|
|
35
|
-
Requires-Dist:
|
|
36
|
-
Requires-Dist:
|
|
37
|
-
Requires-Dist:
|
|
34
|
+
Requires-Dist: backports-datetime-fromisoformat==2.0.3
|
|
35
|
+
Requires-Dist: boto3==1.38.44
|
|
36
|
+
Requires-Dist: botocore==1.38.44
|
|
37
|
+
Requires-Dist: certifi==2025.6.15
|
|
38
|
+
Requires-Dist: charset-normalizer==3.4.2
|
|
38
39
|
Requires-Dist: click==8.1.8
|
|
39
|
-
Requires-Dist: elastic-transport==8.17.
|
|
40
|
-
Requires-Dist: elasticsearch==8.
|
|
41
|
-
Requires-Dist: elasticsearch-dsl==8.
|
|
42
|
-
Requires-Dist: environs==14.
|
|
40
|
+
Requires-Dist: elastic-transport==8.17.1
|
|
41
|
+
Requires-Dist: elasticsearch==8.18.1
|
|
42
|
+
Requires-Dist: elasticsearch-dsl==8.18.0
|
|
43
|
+
Requires-Dist: environs==14.2.0
|
|
43
44
|
Requires-Dist: events==0.5
|
|
44
|
-
Requires-Dist: greenlet==3.
|
|
45
|
+
Requires-Dist: greenlet==3.2.3
|
|
45
46
|
Requires-Dist: idna==3.10
|
|
46
47
|
Requires-Dist: jmespath==1.0.1
|
|
47
|
-
Requires-Dist: marshmallow==
|
|
48
|
+
Requires-Dist: marshmallow==4.0.0
|
|
48
49
|
Requires-Dist: opensearch-dsl==2.1.0
|
|
49
|
-
Requires-Dist: opensearch-py==
|
|
50
|
-
Requires-Dist: packaging==24.2
|
|
50
|
+
Requires-Dist: opensearch-py==3.0.0
|
|
51
51
|
Requires-Dist: psycopg2-binary==2.9.10
|
|
52
52
|
Requires-Dist: python-dateutil==2.9.0.post0
|
|
53
|
-
Requires-Dist: python-dotenv==1.
|
|
54
|
-
Requires-Dist: redis==
|
|
55
|
-
Requires-Dist: requests==2.32.
|
|
53
|
+
Requires-Dist: python-dotenv==1.1.1
|
|
54
|
+
Requires-Dist: redis==6.2.0
|
|
55
|
+
Requires-Dist: requests==2.32.4
|
|
56
56
|
Requires-Dist: requests-aws4auth==1.3.1
|
|
57
|
-
Requires-Dist: s3transfer==0.
|
|
57
|
+
Requires-Dist: s3transfer==0.13.0
|
|
58
58
|
Requires-Dist: six==1.17.0
|
|
59
|
-
Requires-Dist: sqlalchemy==2.0.
|
|
59
|
+
Requires-Dist: sqlalchemy==2.0.41
|
|
60
60
|
Requires-Dist: sqlparse==0.5.3
|
|
61
|
-
Requires-Dist: typing-extensions==4.
|
|
61
|
+
Requires-Dist: typing-extensions==4.14.0
|
|
62
62
|
Requires-Dist: urllib3==1.26.20
|
|
63
63
|
Dynamic: author
|
|
64
64
|
Dynamic: author-email
|
|
@@ -68,6 +68,7 @@ Dynamic: description-content-type
|
|
|
68
68
|
Dynamic: home-page
|
|
69
69
|
Dynamic: keywords
|
|
70
70
|
Dynamic: license
|
|
71
|
+
Dynamic: license-file
|
|
71
72
|
Dynamic: maintainer
|
|
72
73
|
Dynamic: maintainer-email
|
|
73
74
|
Dynamic: project-url
|
|
@@ -18,16 +18,26 @@ logger = logging.getLogger(__name__)
|
|
|
18
18
|
help="Schema config",
|
|
19
19
|
type=click.Path(exists=True),
|
|
20
20
|
)
|
|
21
|
-
@click.option("--host", "-h", help="PG_HOST
|
|
21
|
+
@click.option("--host", "-h", help="PG_HOST override")
|
|
22
22
|
@click.option("--password", is_flag=True, help="Prompt for database password")
|
|
23
|
-
@click.option("--port", "-p", help="PG_PORT
|
|
23
|
+
@click.option("--port", "-p", help="PG_PORT override", type=int)
|
|
24
24
|
@click.option(
|
|
25
25
|
"--teardown",
|
|
26
26
|
"-t",
|
|
27
27
|
is_flag=True,
|
|
28
28
|
help="Teardown database triggers and replication slots",
|
|
29
29
|
)
|
|
30
|
-
@click.option(
|
|
30
|
+
@click.option(
|
|
31
|
+
"--no-create",
|
|
32
|
+
"-nc",
|
|
33
|
+
is_flag=True,
|
|
34
|
+
help=(
|
|
35
|
+
"Skip DDL statement for objects "
|
|
36
|
+
"(Functions, Views & Replication slots) creation"
|
|
37
|
+
),
|
|
38
|
+
default=False,
|
|
39
|
+
)
|
|
40
|
+
@click.option("--user", "-u", help="PG_USER override")
|
|
31
41
|
@click.option(
|
|
32
42
|
"--verbose",
|
|
33
43
|
"-v",
|
|
@@ -43,6 +53,7 @@ def main(
|
|
|
43
53
|
host: str,
|
|
44
54
|
port: int,
|
|
45
55
|
verbose: bool,
|
|
56
|
+
no_create: bool = False,
|
|
46
57
|
) -> None:
|
|
47
58
|
"""Application onetime Bootstrap."""
|
|
48
59
|
kwargs: dict = {
|
|
@@ -75,7 +86,7 @@ def main(
|
|
|
75
86
|
if teardown:
|
|
76
87
|
sync.teardown()
|
|
77
88
|
continue
|
|
78
|
-
sync.setup()
|
|
89
|
+
sync.setup(no_create=no_create)
|
|
79
90
|
logger.info(f"Bootstrap: {sync.database}")
|
|
80
91
|
|
|
81
92
|
|
|
@@ -39,7 +39,6 @@ filtered based on the page number and row numbers.
|
|
|
39
39
|
This systematic and parallel approach optimizes the synchronization process,
|
|
40
40
|
especially in environments challenged by network latency.
|
|
41
41
|
"""
|
|
42
|
-
|
|
43
42
|
import asyncio
|
|
44
43
|
import multiprocessing
|
|
45
44
|
import os
|
|
@@ -48,6 +47,7 @@ import sys
|
|
|
48
47
|
import typing as t
|
|
49
48
|
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
|
|
50
49
|
from dataclasses import dataclass
|
|
50
|
+
from pathlib import Path
|
|
51
51
|
from queue import Queue
|
|
52
52
|
from threading import Thread
|
|
53
53
|
|
|
@@ -415,10 +415,10 @@ def main(config: str, nprocs: int, mode: str, verbose: bool) -> None:
|
|
|
415
415
|
- Track progress across cpus/threads
|
|
416
416
|
- Handle KeyboardInterrupt Exception
|
|
417
417
|
"""
|
|
418
|
-
|
|
419
|
-
show_settings()
|
|
420
418
|
config: str = get_config(config)
|
|
421
419
|
|
|
420
|
+
show_settings(config)
|
|
421
|
+
|
|
422
422
|
for doc in config_loader(config):
|
|
423
423
|
tasks: t.Generator = fetch_tasks(doc)
|
|
424
424
|
if mode == "synchronous":
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
|
+
import time
|
|
5
6
|
import typing as t
|
|
7
|
+
from contextlib import contextmanager
|
|
6
8
|
|
|
7
9
|
import sqlalchemy as sa
|
|
8
10
|
from sqlalchemy.dialects import postgresql # noqa
|
|
@@ -77,20 +79,20 @@ class Payload(object):
|
|
|
77
79
|
|
|
78
80
|
def __init__(
|
|
79
81
|
self,
|
|
80
|
-
tg_op: str =
|
|
81
|
-
table: str =
|
|
82
|
-
schema: str =
|
|
83
|
-
old:
|
|
84
|
-
new:
|
|
85
|
-
xmin: int =
|
|
86
|
-
indices: t.List[str] =
|
|
82
|
+
tg_op: t.Optional[str] = None,
|
|
83
|
+
table: t.Optional[str] = None,
|
|
84
|
+
schema: t.Optional[str] = None,
|
|
85
|
+
old: t.Optional[t.Dict[str, t.Any]] = None,
|
|
86
|
+
new: t.Optional[t.Dict[str, t.Any]] = None,
|
|
87
|
+
xmin: t.Optional[int] = None,
|
|
88
|
+
indices: t.Optional[t.List[str]] = None,
|
|
87
89
|
):
|
|
88
|
-
self.tg_op: str = tg_op
|
|
89
|
-
self.table: str = table
|
|
90
|
-
self.schema: str = schema
|
|
91
|
-
self.old:
|
|
92
|
-
self.new:
|
|
93
|
-
self.xmin:
|
|
90
|
+
self.tg_op: t.Optional[str] = tg_op
|
|
91
|
+
self.table: t.Optional[str] = table
|
|
92
|
+
self.schema: t.Optional[str] = schema
|
|
93
|
+
self.old: t.Dict[str, t.Any] = old or {}
|
|
94
|
+
self.new: t.Dict[str, t.Any] = new or {}
|
|
95
|
+
self.xmin: t.Optional[int] = xmin
|
|
94
96
|
self.indices: t.List[str] = indices
|
|
95
97
|
|
|
96
98
|
@property
|
|
@@ -224,22 +226,27 @@ class Base(object):
|
|
|
224
226
|
|
|
225
227
|
def _can_create_replication_slot(self, slot_name: str) -> None:
|
|
226
228
|
"""Check if the given user can create and destroy replication slots."""
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
229
|
+
with self.advisory_lock(
|
|
230
|
+
slot_name, max_retries=None, retry_interval=0.1
|
|
231
|
+
):
|
|
232
|
+
if self.replication_slots(slot_name):
|
|
233
|
+
logger.exception(
|
|
234
|
+
f"Replication slot {slot_name} already exists"
|
|
235
|
+
)
|
|
236
|
+
self.drop_replication_slot(slot_name)
|
|
230
237
|
|
|
231
|
-
|
|
232
|
-
|
|
238
|
+
try:
|
|
239
|
+
self.create_replication_slot(slot_name)
|
|
233
240
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
241
|
+
except Exception as e:
|
|
242
|
+
logger.exception(f"{e}")
|
|
243
|
+
raise ReplicationSlotError(
|
|
244
|
+
f'PG_USER "{self.engine.url.username}" needs to be '
|
|
245
|
+
f"superuser or have permission to read, create and destroy "
|
|
246
|
+
f"replication slots to perform this action.\n{e}"
|
|
247
|
+
)
|
|
248
|
+
else:
|
|
249
|
+
self.drop_replication_slot(slot_name)
|
|
243
250
|
|
|
244
251
|
# Tables...
|
|
245
252
|
def models(self, table: str, schema: str) -> sa.sql.Alias:
|
|
@@ -413,20 +420,23 @@ class Base(object):
|
|
|
413
420
|
|
|
414
421
|
SELECT * FROM PG_REPLICATION_SLOTS
|
|
415
422
|
"""
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
.
|
|
420
|
-
sa.
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
423
|
+
with self.advisory_lock(
|
|
424
|
+
slot_name, max_retries=None, retry_interval=0.1
|
|
425
|
+
):
|
|
426
|
+
return self.fetchall(
|
|
427
|
+
sa.select("*")
|
|
428
|
+
.select_from(sa.text("PG_REPLICATION_SLOTS"))
|
|
429
|
+
.where(
|
|
430
|
+
sa.and_(
|
|
431
|
+
*[
|
|
432
|
+
sa.column("slot_name") == slot_name,
|
|
433
|
+
sa.column("slot_type") == slot_type,
|
|
434
|
+
sa.column("plugin") == plugin,
|
|
435
|
+
]
|
|
436
|
+
)
|
|
437
|
+
),
|
|
438
|
+
label="replication_slots",
|
|
439
|
+
)
|
|
430
440
|
|
|
431
441
|
def create_replication_slot(self, slot_name: str) -> None:
|
|
432
442
|
"""Create a replication slot.
|
|
@@ -439,14 +449,17 @@ class Base(object):
|
|
|
439
449
|
"""
|
|
440
450
|
logger.debug(f"Creating replication slot: {slot_name}")
|
|
441
451
|
try:
|
|
442
|
-
self.
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
452
|
+
with self.advisory_lock(
|
|
453
|
+
slot_name, max_retries=None, retry_interval=0.1
|
|
454
|
+
):
|
|
455
|
+
self.execute(
|
|
456
|
+
sa.select("*").select_from(
|
|
457
|
+
sa.func.PG_CREATE_LOGICAL_REPLICATION_SLOT(
|
|
458
|
+
slot_name,
|
|
459
|
+
PLUGIN,
|
|
460
|
+
)
|
|
447
461
|
)
|
|
448
462
|
)
|
|
449
|
-
)
|
|
450
463
|
except Exception as e:
|
|
451
464
|
logger.exception(f"{e}")
|
|
452
465
|
raise
|
|
@@ -457,16 +470,84 @@ class Base(object):
|
|
|
457
470
|
logger.debug(f"Dropping replication slot: {slot_name}")
|
|
458
471
|
if self.replication_slots(slot_name):
|
|
459
472
|
try:
|
|
460
|
-
self.
|
|
461
|
-
|
|
462
|
-
|
|
473
|
+
with self.advisory_lock(
|
|
474
|
+
slot_name, max_retries=None, retry_interval=0.1
|
|
475
|
+
):
|
|
476
|
+
self.execute(
|
|
477
|
+
sa.select("*").select_from(
|
|
478
|
+
sa.func.PG_DROP_REPLICATION_SLOT(slot_name),
|
|
479
|
+
)
|
|
463
480
|
)
|
|
464
|
-
)
|
|
465
481
|
except Exception as e:
|
|
466
482
|
logger.exception(f"{e}")
|
|
467
483
|
raise
|
|
468
484
|
logger.debug(f"Dropped replication slot: {slot_name}")
|
|
469
485
|
|
|
486
|
+
def advisory_key(self, slot_name: str) -> int:
|
|
487
|
+
"""Compute a stable bigint advisory key from slot name."""
|
|
488
|
+
return self.fetchone(
|
|
489
|
+
sa.text("SELECT HASHTEXT(:slot)::BIGINT").bindparams(
|
|
490
|
+
slot=slot_name
|
|
491
|
+
)
|
|
492
|
+
)[0]
|
|
493
|
+
|
|
494
|
+
def pg_try_advisory_lock(self, key: int) -> bool:
|
|
495
|
+
"""
|
|
496
|
+
Attempts to acquire an advisory lock based on a hashed slot name.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
bool: True if the lock was acquired, False otherwise.
|
|
500
|
+
"""
|
|
501
|
+
result = self.fetchone(
|
|
502
|
+
sa.text("SELECT PG_TRY_ADVISORY_LOCK(:key)").bindparams(key=key)
|
|
503
|
+
)
|
|
504
|
+
return result[0] if result else False
|
|
505
|
+
|
|
506
|
+
def pg_advisory_unlock(self, key: int) -> bool:
|
|
507
|
+
"""
|
|
508
|
+
Releases an advisory lock associated with the hashed slot name.
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
bool: True if the lock was released, False if it was not held.
|
|
512
|
+
"""
|
|
513
|
+
result = self.fetchone(
|
|
514
|
+
sa.text("SELECT PG_ADVISORY_UNLOCK(:key)").bindparams(key=key)
|
|
515
|
+
)
|
|
516
|
+
return result[0] if result else False
|
|
517
|
+
|
|
518
|
+
@contextmanager
|
|
519
|
+
def advisory_lock(
|
|
520
|
+
self,
|
|
521
|
+
slot_name: str,
|
|
522
|
+
max_retries: int = 5,
|
|
523
|
+
retry_interval: float = 1.0,
|
|
524
|
+
backoff_type: str = "fixed", # or "exponential"
|
|
525
|
+
backoff_factor: float = 2.0,
|
|
526
|
+
):
|
|
527
|
+
"""Context manager to acquire a PostgreSQL advisory lock with optional retries."""
|
|
528
|
+
key: int = self.advisory_key(slot_name)
|
|
529
|
+
attempt: int = 0
|
|
530
|
+
delay: int = retry_interval
|
|
531
|
+
while True:
|
|
532
|
+
if self.pg_try_advisory_lock(key):
|
|
533
|
+
break
|
|
534
|
+
|
|
535
|
+
if max_retries is not None and attempt >= max_retries:
|
|
536
|
+
raise RuntimeError(
|
|
537
|
+
f"Failed to acquire advisory lock for '{slot_name}' after {max_retries} retries."
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
time.sleep(delay)
|
|
541
|
+
if backoff_type == "exponential":
|
|
542
|
+
delay *= backoff_factor
|
|
543
|
+
|
|
544
|
+
attempt += 1
|
|
545
|
+
|
|
546
|
+
try:
|
|
547
|
+
yield
|
|
548
|
+
finally:
|
|
549
|
+
self.pg_advisory_unlock(key)
|
|
550
|
+
|
|
470
551
|
def _logical_slot_changes(
|
|
471
552
|
self,
|
|
472
553
|
slot_name: str,
|
|
@@ -555,17 +636,22 @@ class Base(object):
|
|
|
555
636
|
To get ALL changes and data in existing replication slot:
|
|
556
637
|
SELECT * FROM PG_LOGICAL_SLOT_GET_CHANGES('testdb', NULL, NULL)
|
|
557
638
|
"""
|
|
558
|
-
|
|
559
|
-
slot_name,
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
639
|
+
with self.advisory_lock(
|
|
640
|
+
slot_name, max_retries=None, retry_interval=0.1
|
|
641
|
+
):
|
|
642
|
+
statement: sa.sql.Select = self._logical_slot_changes(
|
|
643
|
+
slot_name,
|
|
644
|
+
sa.func.PG_LOGICAL_SLOT_GET_CHANGES,
|
|
645
|
+
txmin=txmin,
|
|
646
|
+
txmax=txmax,
|
|
647
|
+
upto_lsn=upto_lsn,
|
|
648
|
+
upto_nchanges=upto_nchanges,
|
|
649
|
+
limit=limit,
|
|
650
|
+
offset=offset,
|
|
651
|
+
)
|
|
652
|
+
self.execute(
|
|
653
|
+
statement, options=dict(stream_results=STREAM_RESULTS)
|
|
654
|
+
)
|
|
569
655
|
|
|
570
656
|
def logical_slot_peek_changes(
|
|
571
657
|
self,
|
|
@@ -581,17 +667,20 @@ class Base(object):
|
|
|
581
667
|
|
|
582
668
|
SELECT * FROM PG_LOGICAL_SLOT_PEEK_CHANGES('testdb', NULL, 1)
|
|
583
669
|
"""
|
|
584
|
-
|
|
585
|
-
slot_name,
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
670
|
+
with self.advisory_lock(
|
|
671
|
+
slot_name, max_retries=None, retry_interval=0.1
|
|
672
|
+
):
|
|
673
|
+
statement: sa.sql.Select = self._logical_slot_changes(
|
|
674
|
+
slot_name,
|
|
675
|
+
sa.func.PG_LOGICAL_SLOT_PEEK_CHANGES,
|
|
676
|
+
txmin=txmin,
|
|
677
|
+
txmax=txmax,
|
|
678
|
+
upto_lsn=upto_lsn,
|
|
679
|
+
upto_nchanges=upto_nchanges,
|
|
680
|
+
limit=limit,
|
|
681
|
+
offset=offset,
|
|
682
|
+
)
|
|
683
|
+
return self.fetchall(statement)
|
|
595
684
|
|
|
596
685
|
def logical_slot_count_changes(
|
|
597
686
|
self,
|
|
@@ -615,6 +704,10 @@ class Base(object):
|
|
|
615
704
|
).scalar()
|
|
616
705
|
|
|
617
706
|
# Views...
|
|
707
|
+
|
|
708
|
+
def view_exists(self, name: str, schema: str) -> bool:
|
|
709
|
+
return name in self.views(schema)
|
|
710
|
+
|
|
618
711
|
def create_view(
|
|
619
712
|
self,
|
|
620
713
|
index: str,
|
|
@@ -636,7 +729,9 @@ class Base(object):
|
|
|
636
729
|
def drop_view(self, schema: str) -> None:
|
|
637
730
|
"""Drop a view."""
|
|
638
731
|
logger.debug(f"Dropping view: {schema}.{MATERIALIZED_VIEW}")
|
|
639
|
-
with self.engine.connect()
|
|
732
|
+
with self.engine.connect().execution_options(
|
|
733
|
+
isolation_level="AUTOCOMMIT"
|
|
734
|
+
) as conn:
|
|
640
735
|
conn.execute(DropView(schema, MATERIALIZED_VIEW))
|
|
641
736
|
logger.debug(f"Dropped view: {schema}.{MATERIALIZED_VIEW}")
|
|
642
737
|
|
|
@@ -645,16 +740,44 @@ class Base(object):
|
|
|
645
740
|
) -> None:
|
|
646
741
|
"""Refresh a materialized view."""
|
|
647
742
|
logger.debug(f"Refreshing view: {schema}.{name}")
|
|
648
|
-
with self.engine.connect()
|
|
743
|
+
with self.engine.connect().execution_options(
|
|
744
|
+
isolation_level="AUTOCOMMIT"
|
|
745
|
+
) as conn:
|
|
649
746
|
conn.execute(RefreshView(schema, name, concurrently=concurrently))
|
|
650
747
|
logger.debug(f"Refreshed view: {schema}.{name}")
|
|
651
748
|
|
|
652
749
|
# Triggers...
|
|
750
|
+
|
|
751
|
+
def trigger_exists(self, trigger: str, table: str, schema: str) -> bool:
|
|
752
|
+
"""
|
|
753
|
+
Return True if the user-defined trigger is already present on table in schema.
|
|
754
|
+
"""
|
|
755
|
+
sql: str = """
|
|
756
|
+
SELECT EXISTS (
|
|
757
|
+
SELECT 1
|
|
758
|
+
FROM pg_trigger AS t
|
|
759
|
+
JOIN pg_class AS c ON c.oid = t.tgrelid
|
|
760
|
+
JOIN pg_namespace AS n ON n.oid = c.relnamespace
|
|
761
|
+
WHERE NOT t.tgisinternal -- exclude system triggers
|
|
762
|
+
AND t.tgname = :trigge
|
|
763
|
+
AND c.relname = :table
|
|
764
|
+
AND n.nspname = :schema
|
|
765
|
+
)
|
|
766
|
+
"""
|
|
767
|
+
params: dict = dict(
|
|
768
|
+
trigger=trigger,
|
|
769
|
+
table=table,
|
|
770
|
+
schema=schema,
|
|
771
|
+
)
|
|
772
|
+
with self.engine.connect() as conn:
|
|
773
|
+
return bool(conn.execute(sa.text(sql), params).scalar())
|
|
774
|
+
|
|
653
775
|
def create_triggers(
|
|
654
776
|
self,
|
|
655
777
|
schema: str,
|
|
656
778
|
tables: t.Optional[t.List[str]] = None,
|
|
657
779
|
join_queries: bool = False,
|
|
780
|
+
if_not_exists: bool = False,
|
|
658
781
|
) -> None:
|
|
659
782
|
"""Create a database triggers."""
|
|
660
783
|
queries: t.List[str] = []
|
|
@@ -668,13 +791,18 @@ class Base(object):
|
|
|
668
791
|
("notify", "ROW", ["INSERT", "UPDATE", "DELETE"]),
|
|
669
792
|
("truncate", "STATEMENT", ["TRUNCATE"]),
|
|
670
793
|
]:
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
794
|
+
|
|
795
|
+
if if_not_exists or not self.view_exists(
|
|
796
|
+
MATERIALIZED_VIEW, schema
|
|
797
|
+
):
|
|
798
|
+
|
|
799
|
+
self.drop_triggers(schema, [table])
|
|
800
|
+
queries.append(
|
|
801
|
+
f'CREATE TRIGGER "{schema}_{table}_{name}" '
|
|
802
|
+
f'AFTER {" OR ".join(tg_op)} ON "{schema}"."{table}" '
|
|
803
|
+
f"FOR EACH {level} EXECUTE PROCEDURE "
|
|
804
|
+
f"{schema}.{TRIGGER_FUNC}()",
|
|
805
|
+
)
|
|
678
806
|
if join_queries:
|
|
679
807
|
if queries:
|
|
680
808
|
self.execute(sa.text("; ".join(queries)))
|
|
@@ -706,6 +834,16 @@ class Base(object):
|
|
|
706
834
|
for query in queries:
|
|
707
835
|
self.execute(sa.text(query))
|
|
708
836
|
|
|
837
|
+
def function_exists(self, schema: str) -> bool:
|
|
838
|
+
"""Check if the trigger function exists."""
|
|
839
|
+
return self.exists(
|
|
840
|
+
sa.text(
|
|
841
|
+
f"SELECT 1 FROM pg_proc WHERE proname = :name "
|
|
842
|
+
f"AND pronamespace = (SELECT oid FROM pg_namespace "
|
|
843
|
+
f"WHERE nspname = :schema)"
|
|
844
|
+
).bindparams(name=TRIGGER_FUNC, schema=schema),
|
|
845
|
+
)
|
|
846
|
+
|
|
709
847
|
def create_function(self, schema: str) -> None:
|
|
710
848
|
self.execute(
|
|
711
849
|
sa.text(
|
|
@@ -895,6 +1033,20 @@ class Base(object):
|
|
|
895
1033
|
with self.engine.connect() as conn:
|
|
896
1034
|
return conn.execute(statement).fetchall()
|
|
897
1035
|
|
|
1036
|
+
def exists(
|
|
1037
|
+
self,
|
|
1038
|
+
statement: sa.sql.Select,
|
|
1039
|
+
label: t.Optional[str] = None,
|
|
1040
|
+
literal_binds: bool = False,
|
|
1041
|
+
) -> t.List[sa.engine.Row]:
|
|
1042
|
+
if self.verbose:
|
|
1043
|
+
compiled_query(statement, label=label, literal_binds=literal_binds)
|
|
1044
|
+
with self.engine.connect() as conn:
|
|
1045
|
+
result = conn.execute(statement).fetchone()
|
|
1046
|
+
if result is None:
|
|
1047
|
+
return False
|
|
1048
|
+
return result[0] > 0
|
|
1049
|
+
|
|
898
1050
|
def fetchmany(
|
|
899
1051
|
self,
|
|
900
1052
|
statement: sa.sql.Select,
|
|
@@ -7,7 +7,11 @@ import typing as t
|
|
|
7
7
|
from redis import Redis
|
|
8
8
|
from redis.exceptions import ConnectionError
|
|
9
9
|
|
|
10
|
-
from .settings import
|
|
10
|
+
from .settings import (
|
|
11
|
+
REDIS_READ_CHUNK_SIZE,
|
|
12
|
+
REDIS_RETRY_ON_TIMEOUT,
|
|
13
|
+
REDIS_SOCKET_TIMEOUT,
|
|
14
|
+
)
|
|
11
15
|
from .urls import get_redis_url
|
|
12
16
|
|
|
13
17
|
logger = logging.getLogger(__name__)
|
|
@@ -20,10 +24,12 @@ class RedisQueue(object):
|
|
|
20
24
|
"""Init Simple Queue with Redis Backend."""
|
|
21
25
|
url: str = get_redis_url(**kwargs)
|
|
22
26
|
self.key: str = f"{namespace}:{name}"
|
|
27
|
+
self._meta_key: str = f"{self.key}:meta"
|
|
23
28
|
try:
|
|
24
29
|
self.__db: Redis = Redis.from_url(
|
|
25
30
|
url,
|
|
26
31
|
socket_timeout=REDIS_SOCKET_TIMEOUT,
|
|
32
|
+
retry_on_timeout=REDIS_RETRY_ON_TIMEOUT,
|
|
27
33
|
)
|
|
28
34
|
self.__db.ping()
|
|
29
35
|
except ConnectionError as e:
|
|
@@ -54,3 +60,12 @@ class RedisQueue(object):
|
|
|
54
60
|
"""Delete all items from the named queue."""
|
|
55
61
|
logger.info(f"Deleting redis key: {self.key}")
|
|
56
62
|
self.__db.delete(self.key)
|
|
63
|
+
|
|
64
|
+
def set_meta(self, value: t.Any) -> None:
|
|
65
|
+
"""Store an arbitrary JSON-serialisable value in a dedicated key."""
|
|
66
|
+
self.__db.set(self._meta_key, json.dumps(value))
|
|
67
|
+
|
|
68
|
+
def get_meta(self, default: t.Any = None) -> t.Any:
|
|
69
|
+
"""Retrieve the stored value (or *default* if nothing is set)."""
|
|
70
|
+
raw = self.__db.get(self._meta_key)
|
|
71
|
+
return json.loads(raw) if raw is not None else default
|