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.
Files changed (76) hide show
  1. {pgsync-4.0.0 → pgsync-4.1.0}/PKG-INFO +21 -20
  2. {pgsync-4.0.0 → pgsync-4.1.0}/bin/bootstrap +15 -4
  3. {pgsync-4.0.0 → pgsync-4.1.0}/bin/parallel_sync +3 -3
  4. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/__init__.py +1 -1
  5. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/base.py +234 -82
  6. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/redisqueue.py +16 -1
  7. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/settings.py +25 -3
  8. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/sync.py +324 -247
  9. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/urls.py +34 -15
  10. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/utils.py +264 -246
  11. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/view.py +6 -4
  12. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync.egg-info/PKG-INFO +21 -20
  13. pgsync-4.1.0/pgsync.egg-info/requires.txt +30 -0
  14. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_sync.py +204 -74
  15. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_utils.py +3 -17
  16. pgsync-4.0.0/pgsync.egg-info/requires.txt +0 -30
  17. {pgsync-4.0.0 → pgsync-4.1.0}/AUTHORS.rst +0 -0
  18. {pgsync-4.0.0 → pgsync-4.1.0}/CONTRIBUTING.rst +0 -0
  19. {pgsync-4.0.0 → pgsync-4.1.0}/HISTORY.rst +0 -0
  20. {pgsync-4.0.0 → pgsync-4.1.0}/LICENSE +0 -0
  21. {pgsync-4.0.0 → pgsync-4.1.0}/MANIFEST.in +0 -0
  22. {pgsync-4.0.0 → pgsync-4.1.0}/README.md +0 -0
  23. {pgsync-4.0.0 → pgsync-4.1.0}/README.rst +0 -0
  24. {pgsync-4.0.0 → pgsync-4.1.0}/bin/pgsync +0 -0
  25. {pgsync-4.0.0 → pgsync-4.1.0}/docs/Makefile +0 -0
  26. {pgsync-4.0.0 → pgsync-4.1.0}/docs/authors.rst +0 -0
  27. {pgsync-4.0.0 → pgsync-4.1.0}/docs/changelog.rst +0 -0
  28. {pgsync-4.0.0 → pgsync-4.1.0}/docs/conf.py +0 -0
  29. {pgsync-4.0.0 → pgsync-4.1.0}/docs/contributing.rst +0 -0
  30. {pgsync-4.0.0 → pgsync-4.1.0}/docs/history.rst +0 -0
  31. {pgsync-4.0.0 → pgsync-4.1.0}/docs/index.rst +0 -0
  32. {pgsync-4.0.0 → pgsync-4.1.0}/docs/installation.rst +0 -0
  33. {pgsync-4.0.0 → pgsync-4.1.0}/docs/logo.png +0 -0
  34. {pgsync-4.0.0 → pgsync-4.1.0}/docs/make.bat +0 -0
  35. {pgsync-4.0.0 → pgsync-4.1.0}/docs/readme.rst +0 -0
  36. {pgsync-4.0.0 → pgsync-4.1.0}/docs/usage.rst +0 -0
  37. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/constants.py +0 -0
  38. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/exc.py +0 -0
  39. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/helper.py +0 -0
  40. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/node.py +0 -0
  41. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/plugin.py +0 -0
  42. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/querybuilder.py +0 -0
  43. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/search_client.py +0 -0
  44. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/singleton.py +0 -0
  45. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/transform.py +0 -0
  46. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync/trigger.py +0 -0
  47. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync.egg-info/SOURCES.txt +0 -0
  48. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync.egg-info/dependency_links.txt +0 -0
  49. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync.egg-info/not-zip-safe +0 -0
  50. {pgsync-4.0.0 → pgsync-4.1.0}/pgsync.egg-info/top_level.txt +0 -0
  51. {pgsync-4.0.0 → pgsync-4.1.0}/pyproject.toml +0 -0
  52. {pgsync-4.0.0 → pgsync-4.1.0}/setup.cfg +0 -0
  53. {pgsync-4.0.0 → pgsync-4.1.0}/setup.py +0 -0
  54. {pgsync-4.0.0 → pgsync-4.1.0}/tests/__init__.py +0 -0
  55. {pgsync-4.0.0 → pgsync-4.1.0}/tests/conftest.py +0 -0
  56. {pgsync-4.0.0 → pgsync-4.1.0}/tests/fixtures/schema.json +0 -0
  57. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_base.py +0 -0
  58. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_constants.py +0 -0
  59. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_env_vars.py +0 -0
  60. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_helper.py +0 -0
  61. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_log_handlers.py +0 -0
  62. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_node.py +0 -0
  63. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_query_builder.py +0 -0
  64. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_redisqueue.py +0 -0
  65. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_search_client.py +0 -0
  66. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_settings.py +0 -0
  67. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_sync_nested_children.py +0 -0
  68. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_sync_root.py +0 -0
  69. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_sync_single_child_fk_on_child.py +0 -0
  70. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_sync_single_child_fk_on_parent.py +0 -0
  71. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_transform.py +0 -0
  72. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_trigger.py +0 -0
  73. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_unique_behaviour.py +0 -0
  74. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_urls.py +0 -0
  75. {pgsync-4.0.0 → pgsync-4.1.0}/tests/test_view.py +0 -0
  76. {pgsync-4.0.0 → pgsync-4.1.0}/tests/testing_utils.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: pgsync
3
- Version: 4.0.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
@@ -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: boto3==1.37.11
35
- Requires-Dist: botocore==1.37.11
36
- Requires-Dist: certifi==2025.1.31
37
- Requires-Dist: charset-normalizer==3.4.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
38
39
  Requires-Dist: click==8.1.8
39
- Requires-Dist: elastic-transport==8.17.0
40
- Requires-Dist: elasticsearch==8.17.2
41
- Requires-Dist: elasticsearch-dsl==8.17.1
42
- Requires-Dist: environs==14.1.1
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.26.1
48
+ Requires-Dist: marshmallow==4.0.0
48
49
  Requires-Dist: opensearch-dsl==2.1.0
49
- Requires-Dist: opensearch-py==2.8.0
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.0.1
54
- Requires-Dist: redis==5.2.1
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.11.4
57
+ Requires-Dist: s3transfer==0.13.0
58
58
  Requires-Dist: six==1.17.0
59
- Requires-Dist: sqlalchemy==2.0.39
59
+ Requires-Dist: sqlalchemy==2.0.41
60
60
  Requires-Dist: sqlparse==0.5.3
61
- Requires-Dist: typing-extensions==4.12.2
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 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__ = "4.0.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 "{schema}_{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)))
@@ -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 REDIS_READ_CHUNK_SIZE, REDIS_SOCKET_TIMEOUT
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