pgsync 3.3.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.
Files changed (76) hide show
  1. {pgsync-3.3.0 → pgsync-4.1.0}/PKG-INFO +39 -24
  2. {pgsync-3.3.0 → pgsync-4.1.0}/bin/bootstrap +15 -4
  3. {pgsync-3.3.0 → pgsync-4.1.0}/bin/parallel_sync +3 -3
  4. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/__init__.py +1 -1
  5. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/base.py +237 -85
  6. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/querybuilder.py +10 -12
  7. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/redisqueue.py +16 -1
  8. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/settings.py +25 -3
  9. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/sync.py +328 -252
  10. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/urls.py +34 -15
  11. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/utils.py +264 -246
  12. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/view.py +6 -4
  13. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync.egg-info/PKG-INFO +39 -24
  14. pgsync-4.1.0/pgsync.egg-info/requires.txt +30 -0
  15. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_sync.py +204 -74
  16. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_utils.py +3 -17
  17. pgsync-3.3.0/pgsync.egg-info/requires.txt +0 -30
  18. {pgsync-3.3.0 → pgsync-4.1.0}/AUTHORS.rst +0 -0
  19. {pgsync-3.3.0 → pgsync-4.1.0}/CONTRIBUTING.rst +0 -0
  20. {pgsync-3.3.0 → pgsync-4.1.0}/HISTORY.rst +0 -0
  21. {pgsync-3.3.0 → pgsync-4.1.0}/LICENSE +0 -0
  22. {pgsync-3.3.0 → pgsync-4.1.0}/MANIFEST.in +0 -0
  23. {pgsync-3.3.0 → pgsync-4.1.0}/README.md +0 -0
  24. {pgsync-3.3.0 → pgsync-4.1.0}/README.rst +0 -0
  25. {pgsync-3.3.0 → pgsync-4.1.0}/bin/pgsync +0 -0
  26. {pgsync-3.3.0 → pgsync-4.1.0}/docs/Makefile +0 -0
  27. {pgsync-3.3.0 → pgsync-4.1.0}/docs/authors.rst +0 -0
  28. {pgsync-3.3.0 → pgsync-4.1.0}/docs/changelog.rst +0 -0
  29. {pgsync-3.3.0 → pgsync-4.1.0}/docs/conf.py +0 -0
  30. {pgsync-3.3.0 → pgsync-4.1.0}/docs/contributing.rst +0 -0
  31. {pgsync-3.3.0 → pgsync-4.1.0}/docs/history.rst +0 -0
  32. {pgsync-3.3.0 → pgsync-4.1.0}/docs/index.rst +0 -0
  33. {pgsync-3.3.0 → pgsync-4.1.0}/docs/installation.rst +0 -0
  34. {pgsync-3.3.0 → pgsync-4.1.0}/docs/logo.png +0 -0
  35. {pgsync-3.3.0 → pgsync-4.1.0}/docs/make.bat +0 -0
  36. {pgsync-3.3.0 → pgsync-4.1.0}/docs/readme.rst +0 -0
  37. {pgsync-3.3.0 → pgsync-4.1.0}/docs/usage.rst +0 -0
  38. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/constants.py +0 -0
  39. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/exc.py +0 -0
  40. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/helper.py +0 -0
  41. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/node.py +0 -0
  42. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/plugin.py +0 -0
  43. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/search_client.py +0 -0
  44. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/singleton.py +0 -0
  45. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/transform.py +0 -0
  46. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync/trigger.py +0 -0
  47. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync.egg-info/SOURCES.txt +0 -0
  48. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync.egg-info/dependency_links.txt +0 -0
  49. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync.egg-info/not-zip-safe +0 -0
  50. {pgsync-3.3.0 → pgsync-4.1.0}/pgsync.egg-info/top_level.txt +0 -0
  51. {pgsync-3.3.0 → pgsync-4.1.0}/pyproject.toml +0 -0
  52. {pgsync-3.3.0 → pgsync-4.1.0}/setup.cfg +0 -0
  53. {pgsync-3.3.0 → pgsync-4.1.0}/setup.py +0 -0
  54. {pgsync-3.3.0 → pgsync-4.1.0}/tests/__init__.py +0 -0
  55. {pgsync-3.3.0 → pgsync-4.1.0}/tests/conftest.py +0 -0
  56. {pgsync-3.3.0 → pgsync-4.1.0}/tests/fixtures/schema.json +0 -0
  57. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_base.py +0 -0
  58. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_constants.py +0 -0
  59. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_env_vars.py +0 -0
  60. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_helper.py +0 -0
  61. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_log_handlers.py +0 -0
  62. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_node.py +0 -0
  63. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_query_builder.py +0 -0
  64. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_redisqueue.py +0 -0
  65. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_search_client.py +0 -0
  66. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_settings.py +0 -0
  67. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_sync_nested_children.py +0 -0
  68. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_sync_root.py +0 -0
  69. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_sync_single_child_fk_on_child.py +0 -0
  70. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_sync_single_child_fk_on_parent.py +0 -0
  71. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_transform.py +0 -0
  72. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_trigger.py +0 -0
  73. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_unique_behaviour.py +0 -0
  74. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_urls.py +0 -0
  75. {pgsync-3.3.0 → pgsync-4.1.0}/tests/test_view.py +0 -0
  76. {pgsync-3.3.0 → pgsync-4.1.0}/tests/testing_utils.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: pgsync
3
- Version: 3.3.0
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
@@ -30,36 +30,51 @@ Requires-Python: >=3.9.0
30
30
  Description-Content-Type: text/markdown
31
31
  License-File: LICENSE
32
32
  License-File: AUTHORS.rst
33
- Requires-Dist: async-timeout==5.0.0
34
- Requires-Dist: boto3==1.35.54
35
- Requires-Dist: botocore==1.35.54
36
- Requires-Dist: certifi==2024.8.30
37
- Requires-Dist: charset-normalizer==3.4.0
38
- Requires-Dist: click==8.1.7
39
- Requires-Dist: elastic-transport==8.15.1
40
- Requires-Dist: elasticsearch==8.15.1
41
- Requires-Dist: elasticsearch-dsl==8.15.4
42
- Requires-Dist: environs==11.0.0
33
+ Requires-Dist: async-timeout==5.0.1
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
39
+ Requires-Dist: click==8.1.8
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.1.1
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==3.23.1
48
+ Requires-Dist: marshmallow==4.0.0
48
49
  Requires-Dist: opensearch-dsl==2.1.0
49
- Requires-Dist: opensearch-py==2.7.1
50
- Requires-Dist: packaging==24.1
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.0.1
54
- Requires-Dist: redis==5.2.0
55
- Requires-Dist: requests==2.32.3
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.10.3
58
- Requires-Dist: six==1.16.0
59
- Requires-Dist: sqlalchemy==2.0.36
60
- Requires-Dist: sqlparse==0.5.1
61
- Requires-Dist: typing-extensions==4.12.2
57
+ Requires-Dist: s3transfer==0.13.0
58
+ Requires-Dist: six==1.17.0
59
+ Requires-Dist: sqlalchemy==2.0.41
60
+ Requires-Dist: sqlparse==0.5.3
61
+ Requires-Dist: typing-extensions==4.14.0
62
62
  Requires-Dist: urllib3==1.26.20
63
+ Dynamic: author
64
+ Dynamic: author-email
65
+ Dynamic: classifier
66
+ Dynamic: description
67
+ Dynamic: description-content-type
68
+ Dynamic: home-page
69
+ Dynamic: keywords
70
+ Dynamic: license
71
+ Dynamic: license-file
72
+ Dynamic: maintainer
73
+ Dynamic: maintainer-email
74
+ Dynamic: project-url
75
+ Dynamic: requires-dist
76
+ Dynamic: requires-python
77
+ Dynamic: summary
63
78
 
64
79
  # PostgreSQL to Elasticsearch/OpenSearch sync
65
80
 
@@ -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 overide")
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 overide", type=int)
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("--user", "-u", help="PG_USER overide")
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,4 +2,4 @@
2
2
 
3
3
  __author__ = "Tolu Aina"
4
4
  __email__ = "tolu@pgsync.com"
5
- __version__ = "3.3.0"
5
+ __version__ = "4.1.0"
@@ -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 = t.Optional[None],
81
- table: str = t.Optional[None],
82
- schema: str = t.Optional[None],
83
- old: dict = t.Optional[None],
84
- new: dict = t.Optional[None],
85
- xmin: int = t.Optional[None],
86
- indices: t.List[str] = t.Optional[None],
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: dict = old or {}
92
- self.new: dict = new or {}
93
- self.xmin: str = 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
- if self.replication_slots(slot_name):
228
- logger.exception(f"Replication slot {slot_name} already exists")
229
- self.drop_replication_slot(slot_name)
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
- try:
232
- self.create_replication_slot(slot_name)
238
+ try:
239
+ self.create_replication_slot(slot_name)
233
240
 
234
- except Exception as e:
235
- logger.exception(f"{e}")
236
- raise ReplicationSlotError(
237
- f'PG_USER "{self.engine.url.username}" needs to be '
238
- f"superuser or have permission to read, create and destroy "
239
- f"replication slots to perform this action.\n{e}"
240
- )
241
- else:
242
- self.drop_replication_slot(slot_name)
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
- return self.fetchall(
417
- sa.select("*")
418
- .select_from(sa.text("PG_REPLICATION_SLOTS"))
419
- .where(
420
- sa.and_(
421
- *[
422
- sa.column("slot_name") == slot_name,
423
- sa.column("slot_type") == slot_type,
424
- sa.column("plugin") == plugin,
425
- ]
426
- )
427
- ),
428
- label="replication_slots",
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.execute(
443
- sa.select("*").select_from(
444
- sa.func.PG_CREATE_LOGICAL_REPLICATION_SLOT(
445
- slot_name,
446
- PLUGIN,
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.execute(
461
- sa.select("*").select_from(
462
- sa.func.PG_DROP_REPLICATION_SLOT(slot_name),
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
- statement: sa.sql.Select = self._logical_slot_changes(
559
- slot_name,
560
- sa.func.PG_LOGICAL_SLOT_GET_CHANGES,
561
- txmin=txmin,
562
- txmax=txmax,
563
- upto_lsn=upto_lsn,
564
- upto_nchanges=upto_nchanges,
565
- limit=limit,
566
- offset=offset,
567
- )
568
- self.execute(statement, options=dict(stream_results=STREAM_RESULTS))
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
- statement: sa.sql.Select = self._logical_slot_changes(
585
- slot_name,
586
- sa.func.PG_LOGICAL_SLOT_PEEK_CHANGES,
587
- txmin=txmin,
588
- txmax=txmax,
589
- upto_lsn=upto_lsn,
590
- upto_nchanges=upto_nchanges,
591
- limit=limit,
592
- offset=offset,
593
- )
594
- return self.fetchall(statement)
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() as conn:
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() as conn:
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
- self.drop_triggers(schema, [table])
672
- queries.append(
673
- f'CREATE TRIGGER "{table}_{name}" '
674
- f'AFTER {" OR ".join(tg_op)} ON "{schema}"."{table}" '
675
- f"FOR EACH {level} EXECUTE PROCEDURE "
676
- f"{schema}.{TRIGGER_FUNC}()",
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)))
@@ -696,7 +824,7 @@ class Base(object):
696
824
  logger.debug(f"Dropping trigger on table: {schema}.{table}")
697
825
  for name in ("notify", "truncate"):
698
826
  queries.append(
699
- f'DROP TRIGGER IF EXISTS "{table}_{name}" ON '
827
+ f'DROP TRIGGER IF EXISTS "{schema}_{table}_{name}" ON '
700
828
  f'"{schema}"."{table}"'
701
829
  )
702
830
  if 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(
@@ -732,7 +870,7 @@ class Base(object):
732
870
  self.execute(
733
871
  sa.text(
734
872
  f'ALTER TABLE "{schema}"."{table}" '
735
- f"DISABLE TRIGGER {table}_{name}"
873
+ f"DISABLE TRIGGER {schema}_{table}_{name}"
736
874
  )
737
875
  )
738
876
 
@@ -749,7 +887,7 @@ class Base(object):
749
887
  self.execute(
750
888
  sa.text(
751
889
  f'ALTER TABLE "{schema}"."{table}" '
752
- f"ENABLE TRIGGER {table}_{name}"
890
+ f"ENABLE TRIGGER {schema}_{table}_{name}"
753
891
  )
754
892
  )
755
893
 
@@ -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,