b3-prosaic-harness-postgres 0.2.1__py3-none-any.whl

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.
@@ -0,0 +1,305 @@
1
+ """Synchronous facade over bounded, process-owned PostgreSQL I/O."""
2
+ import asyncio
3
+ from concurrent.futures import TimeoutError as FutureTimeout
4
+ from dataclasses import dataclass
5
+ import logging
6
+ import math
7
+ import os
8
+ import threading
9
+ import time
10
+ import uuid
11
+
12
+ import psycopg
13
+ from psycopg.conninfo import make_conninfo
14
+ from psycopg_pool import AsyncConnectionPool
15
+ from prosaic_harness import StoreError, StoreBusy, StoreIncompatible, StoreUnavailable
16
+
17
+
18
+ def positive(value, name, *, integer=False):
19
+ if (type(value) not in (int, float) or not math.isfinite(value) or value <= 0
20
+ or (integer and type(value) is not int)):
21
+ raise ValueError(f'{name} must be a finite positive {"integer" if integer else "number"}')
22
+ return value
23
+
24
+
25
+ class _PoolRedaction(logging.Filter):
26
+ def __init__(self, prefix):
27
+ super().__init__()
28
+ self.prefix = prefix
29
+
30
+ def filter(self, record):
31
+ if self.prefix in record.getMessage():
32
+ record.msg = 'Harness PostgreSQL pool connection unavailable'
33
+ record.args = ()
34
+ return True
35
+
36
+
37
+ @dataclass(eq=False)
38
+ class _Operation:
39
+ deadline: float
40
+ work_deadline: float
41
+ connection: object = None
42
+ task: object = None
43
+ expired: bool = False
44
+ commit_started: bool = False
45
+
46
+
47
+ class PostgresTransport:
48
+ def __init__(self, dsn, *, operation_timeout_s=10, pool_timeout_s=5,
49
+ connect_timeout_s=5, lock_timeout_s=1, statement_timeout_s=5,
50
+ pool_size=8, max_active_leases=4):
51
+ for name, value in locals().copy().items():
52
+ if name.endswith('_s'):
53
+ positive(value, name)
54
+ positive(pool_size, 'pool_size', integer=True)
55
+ positive(max_active_leases, 'max_active_leases', integer=True)
56
+ if psycopg.pq.version() < 170000:
57
+ raise StoreIncompatible('libpq 17+ is required for bounded cancellation')
58
+ try:
59
+ if not isinstance(dsn, str) or not dsn:
60
+ raise ValueError()
61
+ # This is a connection guard, not permission to disable TLS/options.
62
+ conninfo = make_conninfo(dsn, target_session_attrs='read-write',
63
+ connect_timeout=max(1, math.ceil(connect_timeout_s)))
64
+ except Exception:
65
+ raise StoreUnavailable('invalid database connection configuration') from None
66
+ self.operation_timeout_s = operation_timeout_s
67
+ self.pool_timeout_s = pool_timeout_s
68
+ self.lock_timeout_s = lock_timeout_s
69
+ self.statement_timeout_s = statement_timeout_s
70
+ self._pid = os.getpid()
71
+ self._mutex = threading.RLock()
72
+ self._pending = set()
73
+ self._closing = False
74
+ self._close_lock = threading.Lock()
75
+ self._closed = False
76
+ self._capacity = 2 * (pool_size + max_active_leases)
77
+ self._loop = asyncio.new_event_loop()
78
+ self._thread = threading.Thread(target=self._loop.run_forever,
79
+ name='harness-postgres-io', daemon=True)
80
+ self._prefix = 'harness-' + uuid.uuid4().hex
81
+ self._filter = _PoolRedaction(self._prefix)
82
+ logging.getLogger('psycopg.pool').addFilter(self._filter)
83
+ self._thread.start()
84
+
85
+ async def open_pools():
86
+ settings = dict(conninfo=conninfo, kwargs={'autocommit': True}, min_size=0,
87
+ open=False, timeout=pool_timeout_s,
88
+ reconnect_timeout=connect_timeout_s, num_workers=1)
89
+ self._ordinary = AsyncConnectionPool(**settings, max_size=pool_size,
90
+ max_waiting=pool_size, name=self._prefix + '-work')
91
+ self._renewal = AsyncConnectionPool(**settings, max_size=max_active_leases,
92
+ max_waiting=max_active_leases, name=self._prefix + '-renew')
93
+ await self._ordinary.open()
94
+ await self._renewal.open()
95
+ try:
96
+ asyncio.run_coroutine_threadsafe(open_pools(), self._loop).result(5)
97
+ except Exception:
98
+ self._loop.call_soon_threadsafe(self._loop.stop)
99
+ self._thread.join(1)
100
+ logging.getLogger('psycopg.pool').removeFilter(self._filter)
101
+ raise StoreUnavailable('database pool startup failed') from None
102
+
103
+ def _check_process(self):
104
+ if self._pid != os.getpid():
105
+ raise StoreBusy('construct database stores after worker fork')
106
+ if self._closed:
107
+ raise StoreBusy('database store is closed')
108
+ if self._closing:
109
+ raise StoreBusy('database store is closing')
110
+
111
+ @property
112
+ def pending_operations(self):
113
+ with self._mutex:
114
+ return len(self._pending)
115
+
116
+ def _reserve(self, budget):
117
+ # Reserve synchronously before the loop can be delayed. Registration
118
+ # lasts through actual SQL/runner teardown, not just caller patience.
119
+ self._check_process()
120
+ deadline = time.monotonic() + budget
121
+ operation = _Operation(deadline, deadline - min(2, budget / 2))
122
+ with self._mutex:
123
+ self._check_process()
124
+ if len(self._pending) >= self._capacity:
125
+ raise StoreBusy('database operation capacity exhausted')
126
+ self._pending.add(operation)
127
+ return operation
128
+
129
+ @staticmethod
130
+ def _check_deadline(operation):
131
+ if operation.expired or time.monotonic() >= operation.work_deadline:
132
+ operation.expired = True
133
+ raise StoreUnavailable('database operation deadline exceeded; reconcile before retrying',
134
+ outcome_unknown=operation.commit_started)
135
+
136
+ async def _guard(self, conn):
137
+ row = await (await conn.execute("""SELECT pg_catalog.pg_is_in_recovery(),
138
+ pg_catalog.current_setting('transaction_read_only'),
139
+ pg_catalog.current_setting('fsync'), pg_catalog.current_setting('full_page_writes'),
140
+ pg_catalog.current_setting('synchronous_commit')""")).fetchone()
141
+ if not row or row[0] or row[1:4] != ('off', 'on', 'on') or row[4] not in {'on', 'remote_apply'}:
142
+ raise StoreIncompatible('require writable primary, logged durability and safe WAL settings')
143
+
144
+ async def _sql(self, operation, callback, renewal, budget):
145
+ self._check_deadline(operation)
146
+ pool = self._renewal if renewal else self._ordinary
147
+ conn = await pool.getconn(timeout=min(self.pool_timeout_s, budget))
148
+ operation.connection = conn
149
+ try:
150
+ await self._guard(conn)
151
+ async with conn.transaction():
152
+ await conn.execute("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")
153
+ await conn.execute("SELECT pg_catalog.set_config('search_path','pg_catalog',true), "
154
+ "pg_catalog.set_config('lock_timeout',%s,true), "
155
+ "pg_catalog.set_config('statement_timeout',%s,true)",
156
+ (f'{min(self.lock_timeout_s, budget)*1000:.0f}ms',
157
+ f'{min(self.statement_timeout_s, budget)*1000:.0f}ms'))
158
+ await self._guard(conn)
159
+ self._check_deadline(operation)
160
+ result = await callback(conn)
161
+ self._check_deadline(operation)
162
+ operation.commit_started = True
163
+ return result
164
+ finally:
165
+ await pool.putconn(conn)
166
+
167
+ async def _expire(self, operation):
168
+ operation.expired = True
169
+ # Close before cancellation, avoiding a blocking cancellation round trip.
170
+ if operation.connection is not None:
171
+ await operation.connection.close()
172
+ if operation.task is not None:
173
+ operation.task.cancel()
174
+
175
+ def _retire(self, operation):
176
+ with self._mutex:
177
+ self._pending.discard(operation)
178
+ # Observe orphan exceptions so they cannot print a raw driver error.
179
+ if operation.task is not None and not operation.task.cancelled():
180
+ operation.task.exception()
181
+
182
+ async def execute_async(self, callback, *, renewal=False, timeout_s=None, _operation=None):
183
+ """Internal adapter entry point; callbacks are trusted database operations."""
184
+ budget = self.operation_timeout_s if timeout_s is None else positive(timeout_s, 'timeout_s')
185
+ cleanup = min(2, budget / 2)
186
+ operation = _operation if _operation is not None else self._reserve(budget)
187
+ timer = None
188
+ try:
189
+ self._check_process()
190
+ self._check_deadline(operation)
191
+ remaining = operation.work_deadline - time.monotonic()
192
+ operation.task = asyncio.create_task(self._sql(operation, callback, renewal, remaining))
193
+ timer = self._loop.call_at(self._loop.time() + remaining,
194
+ lambda: asyncio.create_task(self._expire(operation)))
195
+ try:
196
+ result = await asyncio.shield(operation.task)
197
+ except asyncio.CancelledError:
198
+ await self._expire(operation)
199
+ await asyncio.wait({operation.task}, timeout=cleanup)
200
+ if operation.expired:
201
+ raise StoreUnavailable('database operation interrupted; reconcile before retrying',
202
+ outcome_unknown=operation.commit_started) from None
203
+ raise
204
+ if operation.expired or time.monotonic() >= operation.deadline:
205
+ raise StoreUnavailable('database operation deadline exceeded; reconcile before retrying',
206
+ outcome_unknown=operation.commit_started)
207
+ return result
208
+ except StoreError:
209
+ raise
210
+ except psycopg.errors.InsufficientPrivilege:
211
+ raise StoreIncompatible('database role lacks required privileges') from None
212
+ except Exception:
213
+ raise StoreUnavailable('database access failed; restore connectivity and reconcile',
214
+ outcome_unknown=operation.commit_started) from None
215
+ finally:
216
+ if timer is not None:
217
+ timer.cancel()
218
+ if operation.task is None or operation.task.done():
219
+ self._retire(operation)
220
+ else:
221
+ operation.task.add_done_callback(lambda _: self._retire(operation))
222
+
223
+ def call(self, callback, *, renewal=False, timeout_s=None):
224
+ self._check_process()
225
+ if threading.current_thread() is self._thread:
226
+ raise StoreBusy('synchronous database calls cannot run on the I/O thread')
227
+ budget = self.operation_timeout_s if timeout_s is None else positive(timeout_s, 'timeout_s')
228
+ operation = self._reserve(budget)
229
+ coroutine = self.execute_async(callback, renewal=renewal, timeout_s=budget, _operation=operation)
230
+ try:
231
+ future = asyncio.run_coroutine_threadsafe(coroutine, self._loop)
232
+ except BaseException:
233
+ coroutine.close()
234
+ self._retire(operation)
235
+ raise
236
+ # A timed-out caller cannot consume the eventual submitted exception.
237
+ future.add_done_callback(lambda done: None if done.cancelled() else done.exception())
238
+ try:
239
+ return future.result(max(0, operation.deadline - time.monotonic()))
240
+ except FutureTimeout:
241
+ operation.expired = True
242
+ self._loop.call_soon_threadsafe(lambda: asyncio.create_task(self._expire(operation)))
243
+ # Don't cancel the concurrent Future: that reports completion before
244
+ # the queued runner starts/drains, and can orphan its exception.
245
+ raise StoreUnavailable('database operation deadline exceeded; reconcile before retrying',
246
+ outcome_unknown=True) from None
247
+ except BaseException:
248
+ if not future.done():
249
+ operation.expired = True
250
+ self._loop.call_soon_threadsafe(lambda: asyncio.create_task(self._expire(operation)))
251
+ raise
252
+
253
+ def submit(self, coroutine):
254
+ self._check_process()
255
+ return asyncio.run_coroutine_threadsafe(coroutine, self._loop)
256
+
257
+ def close(self):
258
+ if self._pid != os.getpid():
259
+ raise StoreBusy('construct database stores after worker fork')
260
+ if self._closed:
261
+ return
262
+ if threading.current_thread() is self._thread or not self._close_lock.acquire(blocking=False):
263
+ raise StoreBusy('database cleanup is already running')
264
+ async def close_pools():
265
+ await self._ordinary.close(timeout=1)
266
+ await self._renewal.close(timeout=1)
267
+ remaining = asyncio.all_tasks() - {asyncio.current_task()}
268
+ for task in remaining:
269
+ task.cancel()
270
+ if remaining:
271
+ _, pending = await asyncio.wait(remaining, timeout=1)
272
+ if pending:
273
+ raise StoreBusy('database workers have not drained')
274
+ try:
275
+ with self._mutex:
276
+ if self._pending:
277
+ raise StoreBusy('drain pending database operations before closing')
278
+ self._closing = True
279
+ try:
280
+ asyncio.run_coroutine_threadsafe(close_pools(), self._loop).result(5)
281
+ except StoreBusy:
282
+ raise
283
+ except Exception:
284
+ raise StoreBusy('database workers have not drained') from None
285
+ self._loop.call_soon_threadsafe(self._loop.stop)
286
+ self._thread.join(1)
287
+ if self._thread.is_alive():
288
+ raise StoreBusy('database I/O thread has not stopped')
289
+ self._loop.close()
290
+ self._closed = True
291
+ logging.getLogger('psycopg.pool').removeFilter(self._filter)
292
+ finally:
293
+ self._close_lock.release()
294
+
295
+ def __enter__(self):
296
+ self._check_process()
297
+ return self
298
+
299
+ def __exit__(self, exc_type, exc, traceback):
300
+ try:
301
+ self.close()
302
+ except Exception:
303
+ if exc is None:
304
+ raise
305
+ exc.add_note('database cleanup failed; drain pending operations')