continuo-postgres-adapter 0.3.0__tar.gz → 0.3.2__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.
- {continuo_postgres_adapter-0.3.0 → continuo_postgres_adapter-0.3.2}/PKG-INFO +11 -4
- {continuo_postgres_adapter-0.3.0 → continuo_postgres_adapter-0.3.2}/README.md +7 -0
- {continuo_postgres_adapter-0.3.0 → continuo_postgres_adapter-0.3.2}/continuo_postgres_adapter/adapter.py +69 -12
- {continuo_postgres_adapter-0.3.0 → continuo_postgres_adapter-0.3.2}/pyproject.toml +6 -6
- continuo_postgres_adapter-0.3.2/tests/test_adapter_postgres_connect.py +209 -0
- {continuo_postgres_adapter-0.3.0 → continuo_postgres_adapter-0.3.2}/tests/test_integration_postgres_conformance.py +23 -0
- {continuo_postgres_adapter-0.3.0 → continuo_postgres_adapter-0.3.2}/tests/test_integration_runtime_postgres.py +29 -0
- {continuo_postgres_adapter-0.3.0 → continuo_postgres_adapter-0.3.2}/.gitignore +0 -0
- {continuo_postgres_adapter-0.3.0 → continuo_postgres_adapter-0.3.2}/continuo_postgres_adapter/__init__.py +0 -0
- {continuo_postgres_adapter-0.3.0 → continuo_postgres_adapter-0.3.2}/tests/__init__.py +0 -0
- {continuo_postgres_adapter-0.3.0 → continuo_postgres_adapter-0.3.2}/tests/conftest.py +0 -0
- {continuo_postgres_adapter-0.3.0 → continuo_postgres_adapter-0.3.2}/tests/test_adapter_postgres_validation.py +0 -0
- {continuo_postgres_adapter-0.3.0 → continuo_postgres_adapter-0.3.2}/tests/test_adapter_runtime_postgres.py +0 -0
- {continuo_postgres_adapter-0.3.0 → continuo_postgres_adapter-0.3.2}/tests/test_integration_postgres_validation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: continuo-postgres-adapter
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Postgres warehouse adapter for Continuo: validation and python-node runtime.
|
|
5
5
|
Author: Simone Carolini
|
|
6
6
|
Maintainer: Simone Carolini
|
|
@@ -8,9 +8,9 @@ Classifier: Development Status :: 4 - Beta
|
|
|
8
8
|
Classifier: Intended Audience :: Developers
|
|
9
9
|
Classifier: Programming Language :: Python :: 3.14
|
|
10
10
|
Requires-Python: >=3.14
|
|
11
|
-
Requires-Dist: continuo-engine-contract==0.
|
|
12
|
-
Requires-Dist: psycopg2-binary==2.9.
|
|
13
|
-
Requires-Dist: pyarrow==
|
|
11
|
+
Requires-Dist: continuo-engine-contract==0.9.0
|
|
12
|
+
Requires-Dist: psycopg2-binary==2.9.13
|
|
13
|
+
Requires-Dist: pyarrow==26.0.0
|
|
14
14
|
Description-Content-Type: text/markdown
|
|
15
15
|
|
|
16
16
|
# continuo-postgres-adapter
|
|
@@ -27,6 +27,13 @@ registers itself under entry-point group `continuo_engine.adapters` as
|
|
|
27
27
|
Connection env: `POSTGRES_HOST`, `POSTGRES_DB`, `POSTGRES_USER` (required);
|
|
28
28
|
`POSTGRES_PORT` (default 5432), `POSTGRES_PASSWORD` (default empty).
|
|
29
29
|
|
|
30
|
+
`from_env` tries a connection that no server answered (every address of the
|
|
31
|
+
host refused, had no route or sat on an unreachable network) up to 5 times,
|
|
32
|
+
2 s apart, logging each retry: a Job pod's first connection can be refused
|
|
33
|
+
for a few seconds after the pod starts, until the cluster's NetworkPolicy
|
|
34
|
+
engine has admitted the pod. Every other failure, an authentication failure or
|
|
35
|
+
a connect timeout on any address among them, is raised at once.
|
|
36
|
+
|
|
30
37
|
The connection runs with `autocommit = True`; every method owns its
|
|
31
38
|
transaction explicitly. `load()` and `ensure_table()` open an explicit
|
|
32
39
|
`BEGIN` around their statements and `COMMIT`/`ROLLBACK` themselves, so a
|
|
@@ -12,6 +12,13 @@ registers itself under entry-point group `continuo_engine.adapters` as
|
|
|
12
12
|
Connection env: `POSTGRES_HOST`, `POSTGRES_DB`, `POSTGRES_USER` (required);
|
|
13
13
|
`POSTGRES_PORT` (default 5432), `POSTGRES_PASSWORD` (default empty).
|
|
14
14
|
|
|
15
|
+
`from_env` tries a connection that no server answered (every address of the
|
|
16
|
+
host refused, had no route or sat on an unreachable network) up to 5 times,
|
|
17
|
+
2 s apart, logging each retry: a Job pod's first connection can be refused
|
|
18
|
+
for a few seconds after the pod starts, until the cluster's NetworkPolicy
|
|
19
|
+
engine has admitted the pod. Every other failure, an authentication failure or
|
|
20
|
+
a connect timeout on any address among them, is raised at once.
|
|
21
|
+
|
|
15
22
|
The connection runs with `autocommit = True`; every method owns its
|
|
16
23
|
transaction explicitly. `load()` and `ensure_table()` open an explicit
|
|
17
24
|
`BEGIN` around their statements and `COMMIT`/`ROLLBACK` themselves, so a
|
|
@@ -28,8 +28,10 @@ import contextlib
|
|
|
28
28
|
import hashlib
|
|
29
29
|
import logging
|
|
30
30
|
import os
|
|
31
|
+
import re
|
|
32
|
+
import time
|
|
31
33
|
|
|
32
|
-
from collections.abc import Iterator, Sequence
|
|
34
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
33
35
|
from typing import Any
|
|
34
36
|
|
|
35
37
|
import psycopg2 # type: ignore[import-untyped]
|
|
@@ -79,6 +81,42 @@ _DEFAULT_INDEX_TYPE = "btree"
|
|
|
79
81
|
# postgres actually stores.
|
|
80
82
|
_MAX_IDENTIFIER_BYTES = 63
|
|
81
83
|
|
|
84
|
+
# A Job pod's first connection can be refused for a few seconds after the pod
|
|
85
|
+
# starts, until the cluster's NetworkPolicy engine has admitted the pod: an
|
|
86
|
+
# engine that rejects (k3s's kube-router) refuses at once instead of dropping
|
|
87
|
+
# the packets. So a connection the server never answered, refused or
|
|
88
|
+
# unroutable, is tried again. A connect timeout is not: an engine that drops
|
|
89
|
+
# packets only delays the first connection, which TCP's own retransmissions
|
|
90
|
+
# carry through, so a timeout means the server stayed silent for the whole
|
|
91
|
+
# timeout and every retry would wait that long again.
|
|
92
|
+
_CONNECT_ATTEMPTS = 5
|
|
93
|
+
_CONNECT_RETRY_SECONDS = 2.0
|
|
94
|
+
# libpq writes one line per address it tried, the reason after "failed: "
|
|
95
|
+
# (libpq 14 and later) or after "could not connect to server: " (libpq 13 and
|
|
96
|
+
# earlier); pgcode is None for every connection failure, so the text is all
|
|
97
|
+
# there is.
|
|
98
|
+
_ADDRESS_FAILURE = re.compile(r"(?:failed|could not connect to server): ([^\n]*)")
|
|
99
|
+
_UNREACHABLE_REASONS = frozenset({"Connection refused", "No route to host", "Network is unreachable"})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _is_unreachable(exc: "psycopg2.OperationalError") -> bool:
|
|
103
|
+
"""Whether *exc* says that every address of the host refused or could not be routed to.
|
|
104
|
+
|
|
105
|
+
A host name can have several addresses, and libpq reports each one it
|
|
106
|
+
tried. One that timed out means a retry would wait out its timeout again;
|
|
107
|
+
a FATAL line came from a server that answered (a failed authentication, an
|
|
108
|
+
unknown database). libpq 13 and earlier print a timeout as a bare
|
|
109
|
+
"timeout expired" line, with no prefix.
|
|
110
|
+
"""
|
|
111
|
+
message = str(exc)
|
|
112
|
+
reasons = [reason.strip() for reason in _ADDRESS_FAILURE.findall(message)]
|
|
113
|
+
return (
|
|
114
|
+
bool(reasons)
|
|
115
|
+
and all(reason in _UNREACHABLE_REASONS for reason in reasons)
|
|
116
|
+
and "FATAL:" not in message
|
|
117
|
+
and "timeout expired" not in message
|
|
118
|
+
)
|
|
119
|
+
|
|
82
120
|
|
|
83
121
|
def _truncate_explicit_identifier(name: str) -> str:
|
|
84
122
|
"""Truncate an explicit, author-given identifier to at most 63 bytes.
|
|
@@ -418,16 +456,35 @@ class PostgresAdapter(WarehouseAdapter, AppendCapable, UpsertCapable):
|
|
|
418
456
|
return ["POSTGRES_HOST", "POSTGRES_DB", "POSTGRES_USER"]
|
|
419
457
|
|
|
420
458
|
@classmethod
|
|
421
|
-
def from_env(cls) -> "PostgresAdapter":
|
|
422
|
-
"""Connect from POSTGRES_* env (port defaults 5432, password empty).
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
459
|
+
def from_env(cls, *, sleep: Callable[[float], None] = time.sleep) -> "PostgresAdapter":
|
|
460
|
+
"""Connect from POSTGRES_* env (port defaults 5432, password empty).
|
|
461
|
+
|
|
462
|
+
A connection no server answered (refused, no route, network
|
|
463
|
+
unreachable) is tried again, up to ``_CONNECT_ATTEMPTS`` times in all,
|
|
464
|
+
``_CONNECT_RETRY_SECONDS`` apart, each retry logged; the last failure
|
|
465
|
+
is raised. Every other failure is raised at once.
|
|
466
|
+
"""
|
|
467
|
+
params = {
|
|
468
|
+
"host": os.environ["POSTGRES_HOST"],
|
|
469
|
+
"port": os.environ.get("POSTGRES_PORT", "5432"),
|
|
470
|
+
"dbname": os.environ["POSTGRES_DB"],
|
|
471
|
+
"user": os.environ["POSTGRES_USER"],
|
|
472
|
+
"password": os.environ.get("POSTGRES_PASSWORD", ""),
|
|
473
|
+
}
|
|
474
|
+
for attempt in range(1, _CONNECT_ATTEMPTS + 1):
|
|
475
|
+
try:
|
|
476
|
+
conn = psycopg2.connect(**params)
|
|
477
|
+
except psycopg2.OperationalError as exc:
|
|
478
|
+
if attempt == _CONNECT_ATTEMPTS or not _is_unreachable(exc):
|
|
479
|
+
raise
|
|
480
|
+
logger.warning(
|
|
481
|
+
"postgres not reachable yet (attempt %d of %d), retrying in %.0f s: %s",
|
|
482
|
+
attempt, _CONNECT_ATTEMPTS, _CONNECT_RETRY_SECONDS, str(exc).strip(),
|
|
483
|
+
)
|
|
484
|
+
sleep(_CONNECT_RETRY_SECONDS)
|
|
485
|
+
continue
|
|
486
|
+
return cls(conn)
|
|
487
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
431
488
|
|
|
432
489
|
# --- Schema lifecycle ---------------------------------------------------
|
|
433
490
|
|
|
@@ -706,7 +763,7 @@ class PostgresAdapter(WarehouseAdapter, AppendCapable, UpsertCapable):
|
|
|
706
763
|
this engine's physical-layout vocabulary — ``indexes`` — validated by
|
|
707
764
|
:func:`_validated_indexes` before any DDL runs, so a malformed config
|
|
708
765
|
never leaves a half-built table behind (fail closed). Required and
|
|
709
|
-
keyword-only, matching the contract 0.
|
|
766
|
+
keyword-only, matching the contract 0.8.0 port signature. The create
|
|
710
767
|
and every index either all land or none do, in the same explicit
|
|
711
768
|
BEGIN/COMMIT/ROLLBACK shape build_empty_from_columns uses.
|
|
712
769
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[project]
|
|
2
|
-
version = "0.3.
|
|
2
|
+
version = "0.3.2"
|
|
3
3
|
name = "continuo-postgres-adapter"
|
|
4
4
|
description = "Postgres warehouse adapter for Continuo: validation and python-node runtime."
|
|
5
5
|
authors = [{ name = "Simone Carolini" }]
|
|
@@ -12,9 +12,9 @@ dependencies = [
|
|
|
12
12
|
# of this adapter would otherwise accept whatever contract release is
|
|
13
13
|
# newest. This adapter subclasses the contract's WarehouseAdapter port
|
|
14
14
|
# directly, so the pair must stay the tested one.
|
|
15
|
-
"continuo-engine-contract==0.
|
|
16
|
-
"psycopg2-binary==2.9.
|
|
17
|
-
"pyarrow==
|
|
15
|
+
"continuo-engine-contract==0.9.0",
|
|
16
|
+
"psycopg2-binary==2.9.13",
|
|
17
|
+
"pyarrow==26.0.0",
|
|
18
18
|
]
|
|
19
19
|
classifiers = [
|
|
20
20
|
"Development Status :: 4 - Beta",
|
|
@@ -27,8 +27,8 @@ postgres = "continuo_postgres_adapter.adapter:PostgresAdapter"
|
|
|
27
27
|
|
|
28
28
|
[dependency-groups]
|
|
29
29
|
dev = [
|
|
30
|
-
"ruff==0.
|
|
31
|
-
"mypy==2.
|
|
30
|
+
"ruff==0.17.1",
|
|
31
|
+
"mypy==2.4.0",
|
|
32
32
|
]
|
|
33
33
|
test = [
|
|
34
34
|
"pytest==9.1.1",
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Unit tests for ``PostgresAdapter.from_env``'s first connection.
|
|
2
|
+
|
|
3
|
+
A Job pod's first connection can be refused for a few seconds after the pod
|
|
4
|
+
starts, while the cluster's NetworkPolicy engine has not yet admitted the
|
|
5
|
+
pod; an engine that rejects (k3s's kube-router) refuses at once rather than
|
|
6
|
+
dropping packets. ``from_env`` retries only a connection the server never
|
|
7
|
+
answered, refused or unreachable, and raises every other failure at once.
|
|
8
|
+
|
|
9
|
+
The scripted cases raise ``psycopg2.OperationalError`` with libpq's own
|
|
10
|
+
message text (``pgcode`` is ``None`` for every connection failure, so the text
|
|
11
|
+
is all there is). One case connects to a local port nothing listens on, so it
|
|
12
|
+
checks the classification against what the installed libpq really says.
|
|
13
|
+
"""
|
|
14
|
+
import logging
|
|
15
|
+
import socket
|
|
16
|
+
|
|
17
|
+
import psycopg2 # type: ignore[import-untyped]
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
import continuo_postgres_adapter.adapter as adapter_module
|
|
21
|
+
from continuo_postgres_adapter.adapter import PostgresAdapter
|
|
22
|
+
|
|
23
|
+
REFUSED = (
|
|
24
|
+
'connection to server at "continuo-pg-rw" (10.43.101.94), port 5432 failed: '
|
|
25
|
+
"Connection refused\n\tIs the server running on that host and accepting TCP/IP connections?\n"
|
|
26
|
+
)
|
|
27
|
+
# libpq 13 and older word a refused connection this way.
|
|
28
|
+
REFUSED_LIBPQ_13 = (
|
|
29
|
+
"could not connect to server: Connection refused\n\tIs the server running on host "
|
|
30
|
+
'"continuo-pg-rw" (10.43.101.94) and accepting\n\tTCP/IP connections on port 5432?\n'
|
|
31
|
+
)
|
|
32
|
+
NO_ROUTE = (
|
|
33
|
+
'connection to server at "continuo-pg-rw" (10.43.101.94), port 5432 failed: '
|
|
34
|
+
"No route to host\n\tIs the server running on that host and accepting TCP/IP connections?\n"
|
|
35
|
+
)
|
|
36
|
+
NET_UNREACHABLE = (
|
|
37
|
+
'connection to server at "continuo-pg-rw" (10.43.101.94), port 5432 failed: '
|
|
38
|
+
"Network is unreachable\n\tIs the server running on that host and accepting TCP/IP connections?\n"
|
|
39
|
+
)
|
|
40
|
+
AUTH_FAILED = (
|
|
41
|
+
'connection to server at "continuo-pg-rw" (10.43.101.94), port 5432 failed: '
|
|
42
|
+
'FATAL: password authentication failed for user "continuo"\n'
|
|
43
|
+
)
|
|
44
|
+
NO_DATABASE = (
|
|
45
|
+
'connection to server at "continuo-pg-rw" (10.43.101.94), port 5432 failed: '
|
|
46
|
+
'FATAL: database "warehouse" does not exist\n'
|
|
47
|
+
)
|
|
48
|
+
TIMED_OUT = 'connection to server at "continuo-pg-rw" (10.43.101.94), port 5432 failed: timeout expired\n'
|
|
49
|
+
UNKNOWN_HOST = (
|
|
50
|
+
'could not translate host name "continuo-pg-rw" to address: Name or service not known\n'
|
|
51
|
+
)
|
|
52
|
+
# A host name with two addresses: the server answered on the second, so the
|
|
53
|
+
# failure is the server's verdict, not an unreachable host.
|
|
54
|
+
REFUSED_THEN_AUTH_FAILED = (
|
|
55
|
+
'connection to server at "localhost" (::1), port 5432 failed: Connection refused\n'
|
|
56
|
+
"\tIs the server running on that host and accepting TCP/IP connections?\n"
|
|
57
|
+
'connection to server at "localhost" (127.0.0.1), port 5432 failed: '
|
|
58
|
+
'FATAL: password authentication failed for user "continuo"\n'
|
|
59
|
+
)
|
|
60
|
+
# libpq reports every address it tried in one message. One address that stayed
|
|
61
|
+
# silent means the host is not just unadmitted yet, and a retry would wait out
|
|
62
|
+
# its timeout again (libpq 17's text, a real dual-stack localhost).
|
|
63
|
+
REFUSED_THEN_TIMED_OUT = (
|
|
64
|
+
'connection to server at "localhost" (::1), port 5432 failed: Connection refused\n'
|
|
65
|
+
"\tIs the server running on that host and accepting TCP/IP connections?\n"
|
|
66
|
+
'connection to server at "localhost" (127.0.0.1), port 5432 failed: timeout expired\n'
|
|
67
|
+
)
|
|
68
|
+
TIMED_OUT_THEN_REFUSED = (
|
|
69
|
+
'connection to server at "localhost" (127.0.0.1), port 5432 failed: timeout expired\n'
|
|
70
|
+
'connection to server at "localhost" (::1), port 5432 failed: Connection refused\n'
|
|
71
|
+
"\tIs the server running on that host and accepting TCP/IP connections?\n"
|
|
72
|
+
)
|
|
73
|
+
# libpq 13 and earlier print a timeout as a bare line.
|
|
74
|
+
REFUSED_THEN_TIMED_OUT_LIBPQ_13 = (
|
|
75
|
+
"could not connect to server: Connection refused\n\tIs the server running on host "
|
|
76
|
+
'"localhost" (::1) and accepting\n\tTCP/IP connections on port 5432?\n'
|
|
77
|
+
"timeout expired\n"
|
|
78
|
+
)
|
|
79
|
+
REFUSED_TWICE = (
|
|
80
|
+
'connection to server at "localhost" (::1), port 5432 failed: Connection refused\n'
|
|
81
|
+
"\tIs the server running on that host and accepting TCP/IP connections?\n"
|
|
82
|
+
'connection to server at "localhost" (127.0.0.1), port 5432 failed: Connection refused\n'
|
|
83
|
+
"\tIs the server running on that host and accepting TCP/IP connections?\n"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _Connection:
|
|
88
|
+
"""What PostgresAdapter.__init__ touches of a psycopg2 connection."""
|
|
89
|
+
|
|
90
|
+
autocommit = False
|
|
91
|
+
|
|
92
|
+
def close(self):
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _ConnectScript:
|
|
97
|
+
"""psycopg2.connect replacement: raises the next scripted error (None = connect)."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, errors):
|
|
100
|
+
self.errors = list(errors)
|
|
101
|
+
self.calls: list[dict] = []
|
|
102
|
+
|
|
103
|
+
def __call__(self, **kwargs):
|
|
104
|
+
self.calls.append(kwargs)
|
|
105
|
+
error = self.errors.pop(0) if self.errors else None
|
|
106
|
+
if error is not None:
|
|
107
|
+
raise psycopg2.OperationalError(error)
|
|
108
|
+
return _Connection()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.fixture
|
|
112
|
+
def postgres_env(monkeypatch):
|
|
113
|
+
monkeypatch.setenv("POSTGRES_HOST", "continuo-pg-rw")
|
|
114
|
+
monkeypatch.setenv("POSTGRES_PORT", "5432")
|
|
115
|
+
monkeypatch.setenv("POSTGRES_DB", "warehouse")
|
|
116
|
+
monkeypatch.setenv("POSTGRES_USER", "continuo")
|
|
117
|
+
monkeypatch.setenv("POSTGRES_PASSWORD", "s3cret")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.fixture
|
|
121
|
+
def connect_with(monkeypatch, postgres_env):
|
|
122
|
+
def run(errors):
|
|
123
|
+
script = _ConnectScript(errors)
|
|
124
|
+
monkeypatch.setattr(adapter_module.psycopg2, "connect", script)
|
|
125
|
+
return script
|
|
126
|
+
|
|
127
|
+
return run
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.parametrize("message", [REFUSED, REFUSED_LIBPQ_13, NO_ROUTE, NET_UNREACHABLE, REFUSED_TWICE],
|
|
131
|
+
ids=["refused", "refused-libpq-13", "no-route", "net-unreachable", "refused-twice"])
|
|
132
|
+
def test_an_unreachable_server_is_retried_until_it_answers(connect_with, message):
|
|
133
|
+
script = connect_with([message, message, None])
|
|
134
|
+
sleeps: list[float] = []
|
|
135
|
+
|
|
136
|
+
adapter = PostgresAdapter.from_env(sleep=sleeps.append)
|
|
137
|
+
|
|
138
|
+
assert isinstance(adapter, PostgresAdapter)
|
|
139
|
+
assert len(script.calls) == 3
|
|
140
|
+
assert sleeps == [2.0, 2.0]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_every_retry_connects_with_the_same_parameters(connect_with):
|
|
144
|
+
script = connect_with([REFUSED, None])
|
|
145
|
+
|
|
146
|
+
PostgresAdapter.from_env(sleep=lambda _seconds: None)
|
|
147
|
+
|
|
148
|
+
expected = dict(host="continuo-pg-rw", port="5432", dbname="warehouse", user="continuo", password="s3cret")
|
|
149
|
+
assert script.calls == [expected, expected]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_a_server_that_stays_unreachable_fails_after_five_attempts(connect_with):
|
|
153
|
+
script = connect_with([REFUSED] * 50)
|
|
154
|
+
sleeps: list[float] = []
|
|
155
|
+
|
|
156
|
+
with pytest.raises(psycopg2.OperationalError, match="Connection refused"):
|
|
157
|
+
PostgresAdapter.from_env(sleep=sleeps.append)
|
|
158
|
+
|
|
159
|
+
assert len(script.calls) == 5
|
|
160
|
+
assert sleeps == [2.0, 2.0, 2.0, 2.0]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_each_retry_is_logged_with_the_drivers_message(connect_with, caplog):
|
|
164
|
+
connect_with([REFUSED, REFUSED, None])
|
|
165
|
+
caplog.set_level(logging.INFO, logger="continuo_postgres_adapter")
|
|
166
|
+
|
|
167
|
+
PostgresAdapter.from_env(sleep=lambda _seconds: None)
|
|
168
|
+
|
|
169
|
+
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
|
170
|
+
assert len(warnings) == 2
|
|
171
|
+
assert all("port 5432 failed: Connection refused" in r.getMessage() for r in warnings)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@pytest.mark.parametrize("message", [
|
|
175
|
+
AUTH_FAILED, NO_DATABASE, TIMED_OUT, UNKNOWN_HOST, REFUSED_THEN_AUTH_FAILED,
|
|
176
|
+
REFUSED_THEN_TIMED_OUT, TIMED_OUT_THEN_REFUSED, REFUSED_THEN_TIMED_OUT_LIBPQ_13,
|
|
177
|
+
], ids=["auth-failed", "no-database", "timed-out", "unknown-host", "refused-then-auth-failed",
|
|
178
|
+
"refused-then-timed-out", "timed-out-then-refused", "refused-then-timed-out-libpq-13"])
|
|
179
|
+
def test_every_other_connection_failure_is_raised_at_once(connect_with, message):
|
|
180
|
+
script = connect_with([message, None])
|
|
181
|
+
sleeps: list[float] = []
|
|
182
|
+
|
|
183
|
+
with pytest.raises(psycopg2.OperationalError) as caught:
|
|
184
|
+
PostgresAdapter.from_env(sleep=sleeps.append)
|
|
185
|
+
|
|
186
|
+
assert str(caught.value) == message
|
|
187
|
+
assert len(script.calls) == 1 and sleeps == []
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _free_port() -> int:
|
|
191
|
+
"""A loopback port no socket holds, so a connection to it is refused.
|
|
192
|
+
|
|
193
|
+
A port merely bound, without listen(), is not enough: macOS drops the
|
|
194
|
+
connection's SYN instead of refusing it, and the connect times out.
|
|
195
|
+
"""
|
|
196
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
197
|
+
probe.bind(("127.0.0.1", 0))
|
|
198
|
+
return probe.getsockname()[1]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_a_real_refused_connection_is_retried(monkeypatch, postgres_env):
|
|
202
|
+
monkeypatch.setenv("POSTGRES_HOST", "127.0.0.1")
|
|
203
|
+
monkeypatch.setenv("POSTGRES_PORT", str(_free_port()))
|
|
204
|
+
sleeps: list[float] = []
|
|
205
|
+
|
|
206
|
+
with pytest.raises(psycopg2.OperationalError, match="Connection refused"):
|
|
207
|
+
PostgresAdapter.from_env(sleep=sleeps.append)
|
|
208
|
+
|
|
209
|
+
assert sleeps == [2.0, 2.0, 2.0, 2.0]
|
|
@@ -18,6 +18,7 @@ from continuo_engine_contract.testing.conformance import (
|
|
|
18
18
|
AppendConformance,
|
|
19
19
|
ConformanceBase,
|
|
20
20
|
IntrospectionConformance,
|
|
21
|
+
StartupConformance,
|
|
21
22
|
UpsertConformance,
|
|
22
23
|
table_of,
|
|
23
24
|
)
|
|
@@ -48,6 +49,28 @@ def schema(adapter):
|
|
|
48
49
|
adapter.drop_schema(name)
|
|
49
50
|
|
|
50
51
|
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def warehouse_address():
|
|
54
|
+
return "127.0.0.1", int(PG["port"])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.fixture
|
|
58
|
+
def adapter_from_env(monkeypatch):
|
|
59
|
+
def build(host, port):
|
|
60
|
+
monkeypatch.setenv("POSTGRES_HOST", host)
|
|
61
|
+
monkeypatch.setenv("POSTGRES_PORT", str(port))
|
|
62
|
+
monkeypatch.setenv("POSTGRES_DB", PG["dbname"])
|
|
63
|
+
monkeypatch.setenv("POSTGRES_USER", PG["user"])
|
|
64
|
+
monkeypatch.setenv("POSTGRES_PASSWORD", PG["password"])
|
|
65
|
+
return PostgresAdapter.from_env()
|
|
66
|
+
|
|
67
|
+
return build
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestPostgresStartup(StartupConformance):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
51
74
|
class TestPostgresIntrospection(IntrospectionConformance):
|
|
52
75
|
pass
|
|
53
76
|
|
|
@@ -85,6 +85,35 @@ def _seed(schema: str, table: str, ddl: str, rows_sql: str) -> None:
|
|
|
85
85
|
conn.close()
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
@pytest.fixture()
|
|
89
|
+
def pg_env(monkeypatch):
|
|
90
|
+
monkeypatch.setenv("POSTGRES_HOST", PG["host"])
|
|
91
|
+
monkeypatch.setenv("POSTGRES_PORT", str(PG["port"]))
|
|
92
|
+
monkeypatch.setenv("POSTGRES_DB", PG["dbname"])
|
|
93
|
+
monkeypatch.setenv("POSTGRES_USER", PG["user"])
|
|
94
|
+
monkeypatch.setenv("POSTGRES_PASSWORD", PG["password"])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.mark.integration
|
|
98
|
+
def test_from_env_connects_on_the_first_try(pg_env):
|
|
99
|
+
sleeps: list[float] = []
|
|
100
|
+
a = PostgresAdapter.from_env(sleep=sleeps.append)
|
|
101
|
+
try:
|
|
102
|
+
assert a.fetch("SELECT 1 AS one").to_pylist() == [{"one": 1}]
|
|
103
|
+
finally:
|
|
104
|
+
a.close()
|
|
105
|
+
assert sleeps == []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.mark.integration
|
|
109
|
+
def test_from_env_raises_a_failed_authentication_at_once(pg_env, monkeypatch):
|
|
110
|
+
monkeypatch.setenv("POSTGRES_PASSWORD", "not-the-password")
|
|
111
|
+
sleeps: list[float] = []
|
|
112
|
+
with pytest.raises(psycopg2.OperationalError, match="password authentication failed"):
|
|
113
|
+
PostgresAdapter.from_env(sleep=sleeps.append)
|
|
114
|
+
assert sleeps == []
|
|
115
|
+
|
|
116
|
+
|
|
88
117
|
@pytest.mark.integration
|
|
89
118
|
def test_ensure_table_creates_typed_table_with_not_null(clean_schema):
|
|
90
119
|
"""ensure_table creates a typed table; NOT NULL columns show up in the DDL."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|