toolframe-sql-postgres 0.2.0__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.
- toolframe_sql_postgres/__init__.py +1 -0
- toolframe_sql_postgres/backup.py +163 -0
- toolframe_sql_postgres/cli.py +221 -0
- toolframe_sql_postgres/config.py +104 -0
- toolframe_sql_postgres/plugin.py +590 -0
- toolframe_sql_postgres/provision.py +348 -0
- toolframe_sql_postgres/sql.py +203 -0
- toolframe_sql_postgres-0.2.0.dist-info/METADATA +104 -0
- toolframe_sql_postgres-0.2.0.dist-info/RECORD +13 -0
- toolframe_sql_postgres-0.2.0.dist-info/WHEEL +4 -0
- toolframe_sql_postgres-0.2.0.dist-info/entry_points.txt +5 -0
- toolframe_sql_postgres-0.2.0.dist-info/licenses/LICENSE +202 -0
- toolframe_sql_postgres-0.2.0.dist-info/licenses/NOTICE +2 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"""Managed PostgreSQL operations, isolated by application and stable plugin owner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import psycopg
|
|
15
|
+
from psycopg import sql
|
|
16
|
+
from psycopg.rows import dict_row
|
|
17
|
+
from toolframe_sdk import (
|
|
18
|
+
AuthorizationSpec,
|
|
19
|
+
MethodSpec,
|
|
20
|
+
Plugin,
|
|
21
|
+
PluginDefinition,
|
|
22
|
+
PluginError,
|
|
23
|
+
ServiceSpec,
|
|
24
|
+
StoreSchema,
|
|
25
|
+
digest,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .backup import create_backup
|
|
29
|
+
from .config import Settings
|
|
30
|
+
from .sql import migration_statements, prepare
|
|
31
|
+
|
|
32
|
+
SQL_SERVICE = "toolframe.sql"
|
|
33
|
+
ADMIN_SERVICE = "toolframe.sql.admin"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def encoded(value):
|
|
37
|
+
return json.dumps(value, allow_nan=False, separators=(",", ":"))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Transaction:
|
|
42
|
+
connection: psycopg.AsyncConnection
|
|
43
|
+
owner: str
|
|
44
|
+
generation: str
|
|
45
|
+
root_id: str
|
|
46
|
+
deadline: float
|
|
47
|
+
mutation_id: str
|
|
48
|
+
poisoned: bool = False
|
|
49
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PostgresProvider(Plugin):
|
|
53
|
+
def __init__(self, context):
|
|
54
|
+
super().__init__(context)
|
|
55
|
+
self.settings = None
|
|
56
|
+
self.transactions = {}
|
|
57
|
+
self.sweeper = None
|
|
58
|
+
self.administration = asyncio.Lock()
|
|
59
|
+
self.connections = set()
|
|
60
|
+
|
|
61
|
+
async def activate(self):
|
|
62
|
+
self.settings = Settings.from_env()
|
|
63
|
+
if not self.context.state_directory:
|
|
64
|
+
raise PluginError(
|
|
65
|
+
"storage_directory_missing",
|
|
66
|
+
"Core must allocate a private backup directory",
|
|
67
|
+
)
|
|
68
|
+
self.directory = Path(self.context.state_directory) / "postgres"
|
|
69
|
+
self.directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
70
|
+
self.capacity = asyncio.Semaphore(self.settings.connection_limit)
|
|
71
|
+
try:
|
|
72
|
+
async with await psycopg.AsyncConnection.connect(
|
|
73
|
+
self.settings.dsn, row_factory=dict_row, connect_timeout=5
|
|
74
|
+
) as connection:
|
|
75
|
+
identity = await (
|
|
76
|
+
await connection.execute(
|
|
77
|
+
"SELECT application_id FROM _toolframe_platform.identity"
|
|
78
|
+
)
|
|
79
|
+
).fetchall()
|
|
80
|
+
if identity != [{"application_id": self.settings.application_id}]:
|
|
81
|
+
raise PluginError(
|
|
82
|
+
"store_identity_mismatch",
|
|
83
|
+
"Database belongs to a different application",
|
|
84
|
+
)
|
|
85
|
+
except psycopg.Error:
|
|
86
|
+
raise PluginError(
|
|
87
|
+
"postgres_unavailable",
|
|
88
|
+
"Cannot access the provisioned application database",
|
|
89
|
+
) from None
|
|
90
|
+
self.sweeper = asyncio.create_task(self._expire())
|
|
91
|
+
|
|
92
|
+
async def _expire(self):
|
|
93
|
+
while True:
|
|
94
|
+
await asyncio.sleep(0.1)
|
|
95
|
+
for token, transaction in list(self.transactions.items()):
|
|
96
|
+
if (
|
|
97
|
+
transaction.deadline <= time.time()
|
|
98
|
+
and not transaction.lock.locked()
|
|
99
|
+
):
|
|
100
|
+
await self.rollback(token)
|
|
101
|
+
|
|
102
|
+
async def health(self):
|
|
103
|
+
try:
|
|
104
|
+
async with asyncio.timeout(5):
|
|
105
|
+
async with await psycopg.AsyncConnection.connect(
|
|
106
|
+
self.settings.dsn, connect_timeout=5
|
|
107
|
+
) as connection:
|
|
108
|
+
await connection.execute("SELECT 1")
|
|
109
|
+
return {"runtime": "ready", "business": "ready"}
|
|
110
|
+
except psycopg.Error, TimeoutError:
|
|
111
|
+
raise PluginError(
|
|
112
|
+
"postgres_unavailable", "Application database is unavailable"
|
|
113
|
+
) from None
|
|
114
|
+
|
|
115
|
+
async def dispose(self):
|
|
116
|
+
if self.sweeper:
|
|
117
|
+
self.sweeper.cancel()
|
|
118
|
+
await asyncio.gather(self.sweeper, return_exceptions=True)
|
|
119
|
+
for token in list(self.transactions):
|
|
120
|
+
await self.rollback(token)
|
|
121
|
+
for connection in list(self.connections):
|
|
122
|
+
await self.release(connection)
|
|
123
|
+
await super().dispose()
|
|
124
|
+
|
|
125
|
+
def identity(self, *, admin=False, owner=None):
|
|
126
|
+
info = self.context.call_info
|
|
127
|
+
if admin:
|
|
128
|
+
if not info.get("management"):
|
|
129
|
+
raise PluginError("forbidden", "Storage administration is host-only")
|
|
130
|
+
else:
|
|
131
|
+
owner = info.get("owner")
|
|
132
|
+
if not info.get("owner_generation") or not info.get("root_id"):
|
|
133
|
+
raise PluginError(
|
|
134
|
+
"identity_missing",
|
|
135
|
+
"Storage requires a host-issued root and owner generation",
|
|
136
|
+
)
|
|
137
|
+
if info.get("deadline", 0) <= time.time():
|
|
138
|
+
raise PluginError("deadline_exceeded", "Storage root has expired")
|
|
139
|
+
if not owner or not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", owner):
|
|
140
|
+
raise PluginError(
|
|
141
|
+
"invalid_owner", "Storage owner must be a stable plugin ID"
|
|
142
|
+
)
|
|
143
|
+
if info.get("application_id") != self.settings.application_id:
|
|
144
|
+
raise PluginError(
|
|
145
|
+
"store_identity_mismatch", "Database belongs to a different application"
|
|
146
|
+
)
|
|
147
|
+
self.settings.store_dsn(owner)
|
|
148
|
+
return owner, info
|
|
149
|
+
|
|
150
|
+
async def connect(self, owner, deadline):
|
|
151
|
+
acquired = False
|
|
152
|
+
connection = None
|
|
153
|
+
try:
|
|
154
|
+
async with asyncio.timeout(max(0.01, deadline - time.time())):
|
|
155
|
+
await self.capacity.acquire()
|
|
156
|
+
acquired = True
|
|
157
|
+
connection = await psycopg.AsyncConnection.connect(
|
|
158
|
+
self.settings.store_dsn(owner),
|
|
159
|
+
row_factory=dict_row,
|
|
160
|
+
connect_timeout=5,
|
|
161
|
+
)
|
|
162
|
+
self.connections.add(connection)
|
|
163
|
+
ns = self.settings.store_names(owner)
|
|
164
|
+
await connection.execute(
|
|
165
|
+
sql.SQL("SET search_path TO pg_catalog, {}").format(
|
|
166
|
+
sql.Identifier(ns["schema"])
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
await self.timeout(connection, deadline)
|
|
170
|
+
identity = await (
|
|
171
|
+
await connection.execute(
|
|
172
|
+
sql.SQL("SELECT application_id,owner FROM {}.identity").format(
|
|
173
|
+
sql.Identifier(ns["metadata"])
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
).fetchall()
|
|
177
|
+
if identity != [
|
|
178
|
+
{"application_id": self.settings.application_id, "owner": owner}
|
|
179
|
+
]:
|
|
180
|
+
raise PluginError(
|
|
181
|
+
"store_identity_mismatch",
|
|
182
|
+
"Store belongs to another application or owner",
|
|
183
|
+
)
|
|
184
|
+
await connection.commit()
|
|
185
|
+
return connection
|
|
186
|
+
except BaseException:
|
|
187
|
+
if connection:
|
|
188
|
+
await self.release(connection)
|
|
189
|
+
elif acquired:
|
|
190
|
+
self.capacity.release()
|
|
191
|
+
raise
|
|
192
|
+
|
|
193
|
+
async def timeout(self, connection, deadline):
|
|
194
|
+
duration = max(1, int((deadline - time.time()) * 1000))
|
|
195
|
+
await connection.execute(
|
|
196
|
+
"SELECT set_config('statement_timeout', %s, false), set_config('lock_timeout', %s, false)",
|
|
197
|
+
(str(duration), str(duration)),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
async def release(self, connection):
|
|
201
|
+
if connection in self.connections:
|
|
202
|
+
self.connections.remove(connection)
|
|
203
|
+
try:
|
|
204
|
+
await connection.close()
|
|
205
|
+
finally:
|
|
206
|
+
self.capacity.release()
|
|
207
|
+
|
|
208
|
+
async def lock_owner(self, connection, owner):
|
|
209
|
+
key = int.from_bytes(
|
|
210
|
+
hashlib.sha256(
|
|
211
|
+
(self.settings.application_id + "\0" + owner).encode()
|
|
212
|
+
).digest()[:8],
|
|
213
|
+
"big",
|
|
214
|
+
signed=True,
|
|
215
|
+
)
|
|
216
|
+
await connection.execute("SELECT pg_advisory_xact_lock(%s)", (key,))
|
|
217
|
+
|
|
218
|
+
async def inspect(self, connection, owner):
|
|
219
|
+
ns = self.settings.store_names(owner)
|
|
220
|
+
history = await (
|
|
221
|
+
await connection.execute(
|
|
222
|
+
sql.SQL("SELECT * FROM {}.migrations ORDER BY position").format(
|
|
223
|
+
sql.Identifier(ns["metadata"])
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
).fetchall()
|
|
227
|
+
return {
|
|
228
|
+
"exists": bool(history),
|
|
229
|
+
"history": history,
|
|
230
|
+
"revision": history[-1]["revision"] if history else None,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async def plan(self, connection, owner, schema):
|
|
234
|
+
try:
|
|
235
|
+
selected = [
|
|
236
|
+
item.for_dialect("postgresql")
|
|
237
|
+
for item in StoreSchema.model_validate(schema).migrations
|
|
238
|
+
]
|
|
239
|
+
except ValueError:
|
|
240
|
+
raise PluginError(
|
|
241
|
+
"schema_dialect_unsupported",
|
|
242
|
+
"Every migration must declare PostgreSQL SQL",
|
|
243
|
+
) from None
|
|
244
|
+
history = (await self.inspect(connection, owner))["history"]
|
|
245
|
+
for index, row in enumerate(history):
|
|
246
|
+
if index >= len(selected) or (row["revision"], row["checksum"]) != (
|
|
247
|
+
selected[index].revision,
|
|
248
|
+
selected[index].checksum,
|
|
249
|
+
):
|
|
250
|
+
raise PluginError(
|
|
251
|
+
"schema_incompatible",
|
|
252
|
+
"Migration history was changed, removed or downgraded",
|
|
253
|
+
)
|
|
254
|
+
pending = [item.model_dump() for item in selected[len(history) :]]
|
|
255
|
+
for item in pending:
|
|
256
|
+
list(migration_statements(item["sql"]))
|
|
257
|
+
value = {
|
|
258
|
+
"owner": owner,
|
|
259
|
+
"current": history[-1]["revision"] if history else None,
|
|
260
|
+
"target": selected[-1].revision if selected else None,
|
|
261
|
+
"pending": pending,
|
|
262
|
+
"history": history,
|
|
263
|
+
"requires_apply": bool(history and pending),
|
|
264
|
+
}
|
|
265
|
+
value["plan_id"] = digest(value)
|
|
266
|
+
return value
|
|
267
|
+
|
|
268
|
+
async def admin(self, method, params):
|
|
269
|
+
if method == "cleanup_root":
|
|
270
|
+
if not self.context.call_info.get("management"):
|
|
271
|
+
raise PluginError("forbidden", "Root cleanup is host-only")
|
|
272
|
+
if (
|
|
273
|
+
self.context.call_info.get("application_id")
|
|
274
|
+
!= self.settings.application_id
|
|
275
|
+
):
|
|
276
|
+
raise PluginError(
|
|
277
|
+
"store_identity_mismatch",
|
|
278
|
+
"Database belongs to a different application",
|
|
279
|
+
)
|
|
280
|
+
for token, transaction in list(self.transactions.items()):
|
|
281
|
+
if transaction.root_id == params["root_id"]:
|
|
282
|
+
async with transaction.lock:
|
|
283
|
+
await self.rollback(token)
|
|
284
|
+
return {}
|
|
285
|
+
owner, info = self.identity(admin=True, owner=params.get("owner"))
|
|
286
|
+
async with self.administration:
|
|
287
|
+
connection = await self.connect(owner, info["deadline"])
|
|
288
|
+
try:
|
|
289
|
+
if method == "inspect":
|
|
290
|
+
return await self.inspect(connection, owner)
|
|
291
|
+
if method == "plan":
|
|
292
|
+
return await self.plan(connection, owner, params["schema"])
|
|
293
|
+
if method not in {"ensure_store", "apply"}:
|
|
294
|
+
raise PluginError("method_not_found", method)
|
|
295
|
+
if any(tx.owner == owner for tx in self.transactions.values()):
|
|
296
|
+
raise PluginError(
|
|
297
|
+
"store_busy", "Drain all owner transactions before migration"
|
|
298
|
+
)
|
|
299
|
+
await self.lock_owner(connection, owner)
|
|
300
|
+
plan = await self.plan(connection, owner, params["schema"])
|
|
301
|
+
if method == "apply" and params.get("plan_id") != plan["plan_id"]:
|
|
302
|
+
raise PluginError(
|
|
303
|
+
"stale_plan", "Store schema changed after migration planning"
|
|
304
|
+
)
|
|
305
|
+
if method == "ensure_store" and plan["requires_apply"]:
|
|
306
|
+
raise PluginError(
|
|
307
|
+
"migration_required",
|
|
308
|
+
"Existing data requires explicit migration apply",
|
|
309
|
+
plan,
|
|
310
|
+
)
|
|
311
|
+
if not plan["pending"]:
|
|
312
|
+
return {"revision": plan["target"], "committed": False}
|
|
313
|
+
backup = None
|
|
314
|
+
if plan["history"]:
|
|
315
|
+
backup = await create_backup(
|
|
316
|
+
self.settings, self.directory, plan, info["deadline"]
|
|
317
|
+
)
|
|
318
|
+
ns = self.settings.store_names(owner)
|
|
319
|
+
await connection.execute(
|
|
320
|
+
sql.SQL("SET LOCAL search_path TO {}, pg_catalog").format(
|
|
321
|
+
sql.Identifier(ns["schema"])
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
for position, item in enumerate(plan["pending"], len(plan["history"])):
|
|
325
|
+
for statement in migration_statements(item["sql"]):
|
|
326
|
+
await connection.execute(statement)
|
|
327
|
+
checksum = hashlib.sha256(item["sql"].encode()).hexdigest()
|
|
328
|
+
await connection.execute(
|
|
329
|
+
sql.SQL("INSERT INTO {}.migrations VALUES(%s,%s,%s)").format(
|
|
330
|
+
sql.Identifier(ns["metadata"])
|
|
331
|
+
),
|
|
332
|
+
(position, item["revision"], checksum),
|
|
333
|
+
)
|
|
334
|
+
await connection.commit()
|
|
335
|
+
return {
|
|
336
|
+
"revision": plan["target"],
|
|
337
|
+
"committed": True,
|
|
338
|
+
"backup": str(backup) if backup else None,
|
|
339
|
+
}
|
|
340
|
+
finally:
|
|
341
|
+
await self.release(connection)
|
|
342
|
+
|
|
343
|
+
def mutation_id(self, params):
|
|
344
|
+
value = params.get("mutation_id")
|
|
345
|
+
if not isinstance(value, str) or not re.fullmatch(
|
|
346
|
+
r"[a-zA-Z0-9_.:-]{1,128}", value
|
|
347
|
+
):
|
|
348
|
+
raise PluginError(
|
|
349
|
+
"mutation_id_invalid", "A bounded stable mutation ID is required"
|
|
350
|
+
)
|
|
351
|
+
return value
|
|
352
|
+
|
|
353
|
+
async def receipt(self, connection, owner, identifier, fingerprint=None):
|
|
354
|
+
ns = self.settings.store_names(owner)
|
|
355
|
+
row = await (
|
|
356
|
+
await connection.execute(
|
|
357
|
+
sql.SQL(
|
|
358
|
+
"SELECT fingerprint,result FROM {}.receipts WHERE id=%s"
|
|
359
|
+
).format(sql.Identifier(ns["metadata"])),
|
|
360
|
+
(identifier,),
|
|
361
|
+
)
|
|
362
|
+
).fetchone()
|
|
363
|
+
if row and fingerprint is not None and row["fingerprint"] != fingerprint:
|
|
364
|
+
raise PluginError(
|
|
365
|
+
"mutation_conflict",
|
|
366
|
+
"Mutation ID already refers to different SQL/parameters",
|
|
367
|
+
)
|
|
368
|
+
return json.loads(row["result"]) if row else None
|
|
369
|
+
|
|
370
|
+
async def save_receipt(self, connection, owner, identifier, fingerprint, result):
|
|
371
|
+
ns = self.settings.store_names(owner)
|
|
372
|
+
await connection.execute(
|
|
373
|
+
sql.SQL("INSERT INTO {}.receipts VALUES(%s,%s,%s)").format(
|
|
374
|
+
sql.Identifier(ns["metadata"])
|
|
375
|
+
),
|
|
376
|
+
(identifier, fingerprint, encoded(result)),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
async def rollback(self, token):
|
|
380
|
+
transaction = self.transactions.pop(token, None)
|
|
381
|
+
if transaction:
|
|
382
|
+
await self.release(transaction.connection)
|
|
383
|
+
|
|
384
|
+
def transaction(self, token, owner, info):
|
|
385
|
+
transaction = self.transactions.get(token)
|
|
386
|
+
if not transaction or (
|
|
387
|
+
transaction.owner,
|
|
388
|
+
transaction.generation,
|
|
389
|
+
transaction.root_id,
|
|
390
|
+
) != (owner, info["owner_generation"], info["root_id"]):
|
|
391
|
+
raise PluginError(
|
|
392
|
+
"transaction_invalid",
|
|
393
|
+
"Transaction is not owned by this root and generation",
|
|
394
|
+
)
|
|
395
|
+
if transaction.deadline <= time.time():
|
|
396
|
+
raise PluginError("transaction_expired", "Transaction deadline exceeded")
|
|
397
|
+
return transaction
|
|
398
|
+
|
|
399
|
+
async def business(self, method, params):
|
|
400
|
+
owner, info = self.identity()
|
|
401
|
+
token = params.get("transaction")
|
|
402
|
+
transaction = self.transaction(token, owner, info) if token else None
|
|
403
|
+
if transaction:
|
|
404
|
+
async with transaction.lock:
|
|
405
|
+
if self.transactions.get(token) is not transaction:
|
|
406
|
+
raise PluginError(
|
|
407
|
+
"transaction_invalid", "Transaction was already settled"
|
|
408
|
+
)
|
|
409
|
+
return await self.operation(owner, info, method, params, transaction)
|
|
410
|
+
return await self.operation(owner, info, method, params, None)
|
|
411
|
+
|
|
412
|
+
async def operation(self, owner, info, method, params, transaction):
|
|
413
|
+
if transaction and transaction.poisoned and method != "rollback":
|
|
414
|
+
raise PluginError(
|
|
415
|
+
"transaction_poisoned",
|
|
416
|
+
"A failed statement poisoned this transaction; roll it back",
|
|
417
|
+
)
|
|
418
|
+
connection = (
|
|
419
|
+
transaction.connection
|
|
420
|
+
if transaction
|
|
421
|
+
else await self.connect(owner, info["deadline"])
|
|
422
|
+
)
|
|
423
|
+
keep = transaction is not None
|
|
424
|
+
try:
|
|
425
|
+
if method == "rollback":
|
|
426
|
+
if not transaction:
|
|
427
|
+
raise PluginError(
|
|
428
|
+
"transaction_required", "Rollback needs a transaction"
|
|
429
|
+
)
|
|
430
|
+
await self.rollback(params["transaction"])
|
|
431
|
+
return {"rolled_back": True}
|
|
432
|
+
await self.timeout(connection, info["deadline"])
|
|
433
|
+
if method == "receipt":
|
|
434
|
+
return {
|
|
435
|
+
"receipt": await self.receipt(
|
|
436
|
+
connection, owner, self.mutation_id(params)
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
if method == "begin":
|
|
440
|
+
if transaction:
|
|
441
|
+
raise PluginError(
|
|
442
|
+
"nested_transaction", "Nested transactions are unsupported"
|
|
443
|
+
)
|
|
444
|
+
identifier = self.mutation_id(params)
|
|
445
|
+
await self.lock_owner(connection, owner)
|
|
446
|
+
prior = await self.receipt(connection, owner, identifier, "transaction")
|
|
447
|
+
if prior is not None:
|
|
448
|
+
return prior
|
|
449
|
+
token = uuid.uuid4().hex
|
|
450
|
+
self.transactions[token] = Transaction(
|
|
451
|
+
connection,
|
|
452
|
+
owner,
|
|
453
|
+
info["owner_generation"],
|
|
454
|
+
info["root_id"],
|
|
455
|
+
info["deadline"],
|
|
456
|
+
identifier,
|
|
457
|
+
)
|
|
458
|
+
keep = True
|
|
459
|
+
return {"transaction": token, "mutation_id": identifier}
|
|
460
|
+
if method == "commit":
|
|
461
|
+
if not transaction:
|
|
462
|
+
raise PluginError(
|
|
463
|
+
"transaction_required", "Commit needs a transaction"
|
|
464
|
+
)
|
|
465
|
+
result = {"committed": True, "mutation_id": transaction.mutation_id}
|
|
466
|
+
await self.save_receipt(
|
|
467
|
+
connection, owner, transaction.mutation_id, "transaction", result
|
|
468
|
+
)
|
|
469
|
+
await connection.commit()
|
|
470
|
+
self.transactions.pop(params["transaction"])
|
|
471
|
+
await self.release(connection)
|
|
472
|
+
return result
|
|
473
|
+
if method not in {"query", "execute", "execute_returning"}:
|
|
474
|
+
raise PluginError("method_not_found", method)
|
|
475
|
+
query, values = prepare(params["sql"], params.get("parameters", []), method)
|
|
476
|
+
fingerprint = digest(
|
|
477
|
+
{
|
|
478
|
+
"sql": params["sql"],
|
|
479
|
+
"parameters": params.get("parameters", []),
|
|
480
|
+
"method": method,
|
|
481
|
+
}
|
|
482
|
+
)
|
|
483
|
+
identifier = None
|
|
484
|
+
if method != "query" and not transaction:
|
|
485
|
+
identifier = self.mutation_id(params)
|
|
486
|
+
await self.lock_owner(connection, owner)
|
|
487
|
+
prior = await self.receipt(connection, owner, identifier, fingerprint)
|
|
488
|
+
if prior is not None:
|
|
489
|
+
return prior
|
|
490
|
+
ns = self.settings.store_names(owner)
|
|
491
|
+
await connection.execute(
|
|
492
|
+
sql.SQL("SET LOCAL ROLE {}").format(sql.Identifier(ns["runtime"]))
|
|
493
|
+
)
|
|
494
|
+
async with psycopg.AsyncRawCursor(
|
|
495
|
+
connection, row_factory=dict_row
|
|
496
|
+
) as cursor:
|
|
497
|
+
await cursor.execute(query, values)
|
|
498
|
+
if method in {"query", "execute_returning"}:
|
|
499
|
+
rows = await cursor.fetchmany(10001)
|
|
500
|
+
if len(rows) > 10000:
|
|
501
|
+
raise PluginError(
|
|
502
|
+
"result_too_large",
|
|
503
|
+
"Use LIMIT/pagination; query exceeds 10,000 rows",
|
|
504
|
+
)
|
|
505
|
+
result = (
|
|
506
|
+
rows
|
|
507
|
+
if method == "query"
|
|
508
|
+
else {"rows": rows, "rowcount": cursor.rowcount}
|
|
509
|
+
)
|
|
510
|
+
else:
|
|
511
|
+
result = {"rowcount": cursor.rowcount, "lastrowid": None}
|
|
512
|
+
await connection.execute("RESET ROLE")
|
|
513
|
+
if len(encoded(result).encode()) > 900_000:
|
|
514
|
+
raise PluginError(
|
|
515
|
+
"result_too_large", "Query exceeds the RPC result budget"
|
|
516
|
+
)
|
|
517
|
+
if identifier:
|
|
518
|
+
await self.save_receipt(
|
|
519
|
+
connection, owner, identifier, fingerprint, result
|
|
520
|
+
)
|
|
521
|
+
await connection.commit()
|
|
522
|
+
return result
|
|
523
|
+
except BaseException:
|
|
524
|
+
if transaction:
|
|
525
|
+
transaction.poisoned = True
|
|
526
|
+
# A cancelled RPC must not retain a connection/owner lock until
|
|
527
|
+
# the root deadline. A commit error can have an unknown outcome:
|
|
528
|
+
# discard its connection and reconcile by durable mutation ID.
|
|
529
|
+
if method == "commit" or asyncio.current_task().cancelling():
|
|
530
|
+
await self.rollback(params["transaction"])
|
|
531
|
+
raise
|
|
532
|
+
finally:
|
|
533
|
+
if not keep:
|
|
534
|
+
await self.release(connection)
|
|
535
|
+
|
|
536
|
+
async def invoke(self, service, method, params):
|
|
537
|
+
try:
|
|
538
|
+
deadline = self.context.call_info.get("deadline")
|
|
539
|
+
# Cleanup remains callable after the root expires.
|
|
540
|
+
timeout = (
|
|
541
|
+
None
|
|
542
|
+
if method == "cleanup_root" or deadline is None
|
|
543
|
+
else max(0, deadline - time.time())
|
|
544
|
+
)
|
|
545
|
+
async with asyncio.timeout(timeout):
|
|
546
|
+
if service == ADMIN_SERVICE:
|
|
547
|
+
return await self.admin(method, params)
|
|
548
|
+
if service == SQL_SERVICE:
|
|
549
|
+
return await self.business(method, params)
|
|
550
|
+
raise PluginError("method_not_found", method)
|
|
551
|
+
except psycopg.Error, TimeoutError, ValueError, TypeError:
|
|
552
|
+
# Driver diagnostics can contain credentials or complete row values.
|
|
553
|
+
raise PluginError(
|
|
554
|
+
"sql_failed",
|
|
555
|
+
"PostgreSQL operation failed or exceeded its deadline; inspect database availability and schema",
|
|
556
|
+
) from None
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
plugin = PluginDefinition(
|
|
560
|
+
id="sql-postgres",
|
|
561
|
+
runtime_validation="separate_application",
|
|
562
|
+
kind="provider",
|
|
563
|
+
provider_kind="sql",
|
|
564
|
+
factory="toolframe_sql_postgres.plugin:PostgresProvider",
|
|
565
|
+
authorization=AuthorizationSpec(),
|
|
566
|
+
services={
|
|
567
|
+
SQL_SERVICE: ServiceSpec(
|
|
568
|
+
methods={
|
|
569
|
+
name: MethodSpec(
|
|
570
|
+
audience="internal", read_only=name in {"query", "receipt"}
|
|
571
|
+
)
|
|
572
|
+
for name in (
|
|
573
|
+
"query",
|
|
574
|
+
"execute",
|
|
575
|
+
"execute_returning",
|
|
576
|
+
"begin",
|
|
577
|
+
"commit",
|
|
578
|
+
"rollback",
|
|
579
|
+
"receipt",
|
|
580
|
+
)
|
|
581
|
+
}
|
|
582
|
+
),
|
|
583
|
+
ADMIN_SERVICE: ServiceSpec(
|
|
584
|
+
methods={
|
|
585
|
+
name: MethodSpec(audience="internal")
|
|
586
|
+
for name in ("inspect", "ensure_store", "plan", "apply", "cleanup_root")
|
|
587
|
+
}
|
|
588
|
+
),
|
|
589
|
+
},
|
|
590
|
+
)
|