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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: continuo-postgres-adapter
3
- Version: 0.3.0
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.8.0
12
- Requires-Dist: psycopg2-binary==2.9.12
13
- Requires-Dist: pyarrow==25.0.1
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
- conn = psycopg2.connect(
424
- host=os.environ["POSTGRES_HOST"],
425
- port=os.environ.get("POSTGRES_PORT", "5432"),
426
- dbname=os.environ["POSTGRES_DB"],
427
- user=os.environ["POSTGRES_USER"],
428
- password=os.environ.get("POSTGRES_PASSWORD", ""),
429
- )
430
- return cls(conn)
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.6.0 port signature. The create
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.0"
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.8.0",
16
- "psycopg2-binary==2.9.12",
17
- "pyarrow==25.0.1",
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.16.5",
31
- "mypy==2.3.1",
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."""