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.
- b3_prosaic_harness_postgres-0.2.1.dist-info/METADATA +222 -0
- b3_prosaic_harness_postgres-0.2.1.dist-info/RECORD +13 -0
- b3_prosaic_harness_postgres-0.2.1.dist-info/WHEEL +5 -0
- b3_prosaic_harness_postgres-0.2.1.dist-info/entry_points.txt +2 -0
- b3_prosaic_harness_postgres-0.2.1.dist-info/licenses/LICENSE +202 -0
- b3_prosaic_harness_postgres-0.2.1.dist-info/licenses/NOTICE +5 -0
- b3_prosaic_harness_postgres-0.2.1.dist-info/top_level.txt +1 -0
- prosaic_harness_postgres/__init__.py +6 -0
- prosaic_harness_postgres/cli.py +81 -0
- prosaic_harness_postgres/leases.py +64 -0
- prosaic_harness_postgres/schema.py +135 -0
- prosaic_harness_postgres/store.py +277 -0
- prosaic_harness_postgres/transport.py +305 -0
|
@@ -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')
|