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.
@@ -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
+ )