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,135 @@
|
|
|
1
|
+
"""Explicit, transactional version-1 setup and read-only readiness checks."""
|
|
2
|
+
from prosaic_harness import StoreUninitialized, StoreIncompatible
|
|
3
|
+
from prosaic_harness.run_store import STORAGE_CONTRACT_VERSION
|
|
4
|
+
|
|
5
|
+
SCHEMA_VERSION = 1
|
|
6
|
+
MIGRATION_LOCK = 0x50524f53414943
|
|
7
|
+
|
|
8
|
+
DDL = """
|
|
9
|
+
CREATE SCHEMA IF NOT EXISTS prosaic_harness;
|
|
10
|
+
CREATE TABLE prosaic_harness.schema_version (
|
|
11
|
+
singleton BOOLEAN PRIMARY KEY CHECK (singleton), schema_version INTEGER NOT NULL,
|
|
12
|
+
storage_contract_version INTEGER NOT NULL);
|
|
13
|
+
CREATE TABLE prosaic_harness.leases (
|
|
14
|
+
namespace TEXT NOT NULL CHECK (namespace ~ '^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$'),
|
|
15
|
+
run_id TEXT NOT NULL CHECK (run_id ~ '^[0-9a-f]{32}$'),
|
|
16
|
+
generation BIGINT NOT NULL DEFAULT 0 CHECK (generation >= 0), owner TEXT,
|
|
17
|
+
expires_at TIMESTAMPTZ, PRIMARY KEY(namespace, run_id),
|
|
18
|
+
CHECK ((owner IS NULL) = (expires_at IS NULL)));
|
|
19
|
+
CREATE TABLE prosaic_harness.runs (
|
|
20
|
+
namespace TEXT NOT NULL, run_id TEXT NOT NULL,
|
|
21
|
+
revision BIGINT NOT NULL CHECK(revision > 0),
|
|
22
|
+
document TEXT NOT NULL CHECK(octet_length(document) <= 8388608),
|
|
23
|
+
PRIMARY KEY(namespace, run_id),
|
|
24
|
+
FOREIGN KEY(namespace, run_id) REFERENCES prosaic_harness.leases(namespace, run_id));
|
|
25
|
+
CREATE TABLE prosaic_harness.receipts (
|
|
26
|
+
namespace TEXT NOT NULL, run_id TEXT NOT NULL,
|
|
27
|
+
invocation_id TEXT NOT NULL CHECK(invocation_id ~ '^[0-9a-f]{32}$'),
|
|
28
|
+
document TEXT NOT NULL CHECK(octet_length(document) <= 8388608),
|
|
29
|
+
sha256 TEXT NOT NULL CHECK(sha256 ~ '^[0-9a-f]{64}$'),
|
|
30
|
+
PRIMARY KEY(namespace, run_id, invocation_id),
|
|
31
|
+
FOREIGN KEY(namespace, run_id) REFERENCES prosaic_harness.runs(namespace, run_id));
|
|
32
|
+
INSERT INTO prosaic_harness.schema_version VALUES(TRUE, 1, 1);
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
COLUMNS = {
|
|
36
|
+
'schema_version': {'singleton': 'boolean', 'schema_version': 'integer', 'storage_contract_version': 'integer'},
|
|
37
|
+
'leases': {'namespace': 'text', 'run_id': 'text', 'generation': 'bigint', 'owner': 'text', 'expires_at': 'timestamp with time zone'},
|
|
38
|
+
'runs': {'namespace': 'text', 'run_id': 'text', 'revision': 'bigint', 'document': 'text'},
|
|
39
|
+
'receipts': {'namespace': 'text', 'run_id': 'text', 'invocation_id': 'text', 'document': 'text', 'sha256': 'text'},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
CONSTRAINTS = {
|
|
43
|
+
'schema_version': {'CHECK (singleton)', 'PRIMARY KEY (singleton)'},
|
|
44
|
+
'leases': {
|
|
45
|
+
"CHECK ((namespace ~ '^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$'::text))",
|
|
46
|
+
"CHECK ((run_id ~ '^[0-9a-f]{32}$'::text))",
|
|
47
|
+
'CHECK ((generation >= 0))', 'CHECK (((owner IS NULL) = (expires_at IS NULL)))',
|
|
48
|
+
'PRIMARY KEY (namespace, run_id)'},
|
|
49
|
+
'runs': {'CHECK ((revision > 0))', 'CHECK ((octet_length(document) <= 8388608))',
|
|
50
|
+
'PRIMARY KEY (namespace, run_id)',
|
|
51
|
+
'FOREIGN KEY (namespace, run_id) REFERENCES prosaic_harness.leases(namespace, run_id)'},
|
|
52
|
+
'receipts': {"CHECK ((invocation_id ~ '^[0-9a-f]{32}$'::text))",
|
|
53
|
+
"CHECK ((sha256 ~ '^[0-9a-f]{64}$'::text))",
|
|
54
|
+
'CHECK ((octet_length(document) <= 8388608))',
|
|
55
|
+
'PRIMARY KEY (namespace, run_id, invocation_id)',
|
|
56
|
+
'FOREIGN KEY (namespace, run_id) REFERENCES prosaic_harness.runs(namespace, run_id)'},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def ready_on_connection(conn):
|
|
61
|
+
exists = await (await conn.execute("SELECT pg_catalog.to_regclass('prosaic_harness.schema_version')")).fetchone()
|
|
62
|
+
if not exists or exists[0] is None:
|
|
63
|
+
raise StoreUninitialized('run init with migration credentials before accepting work')
|
|
64
|
+
tables = await (await conn.execute("""SELECT c.relname, c.relpersistence, c.relkind
|
|
65
|
+
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
|
|
66
|
+
WHERE n.nspname='prosaic_harness' AND c.relname = ANY(%s)""", (list(COLUMNS),))).fetchall()
|
|
67
|
+
if {r[0] for r in tables} != set(COLUMNS) or any(r[1:] != ('p', 'r') for r in tables):
|
|
68
|
+
raise StoreIncompatible('owned schema must contain permanent logged adapter tables')
|
|
69
|
+
columns = await (await conn.execute("""SELECT c.relname, a.attname, pg_catalog.format_type(a.atttypid,a.atttypmod), a.attnotnull
|
|
70
|
+
FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid=a.attrelid
|
|
71
|
+
JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
|
|
72
|
+
WHERE n.nspname='prosaic_harness' AND c.relname = ANY(%s)
|
|
73
|
+
AND a.attnum>0 AND NOT a.attisdropped""", (list(COLUMNS),))).fetchall()
|
|
74
|
+
actual = {name: {} for name in COLUMNS}
|
|
75
|
+
for table, column, kind, notnull in columns:
|
|
76
|
+
actual[table][column] = kind
|
|
77
|
+
if notnull != ((table, column) not in {('leases', 'owner'), ('leases', 'expires_at')}):
|
|
78
|
+
raise StoreIncompatible('adapter schema nullability is incompatible')
|
|
79
|
+
if actual != COLUMNS:
|
|
80
|
+
raise StoreIncompatible('adapter schema columns are incompatible')
|
|
81
|
+
constraints = await (await conn.execute("""SELECT c.relname,
|
|
82
|
+
pg_catalog.pg_get_constraintdef(k.oid), k.convalidated, k.contype
|
|
83
|
+
FROM pg_catalog.pg_constraint k JOIN pg_catalog.pg_class c ON c.oid=k.conrelid
|
|
84
|
+
JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
|
|
85
|
+
WHERE n.nspname='prosaic_harness' AND c.relname = ANY(%s)""", (list(COLUMNS),))).fetchall()
|
|
86
|
+
actual_constraints = {name: set() for name in COLUMNS}
|
|
87
|
+
for table, definition, validated, kind in constraints:
|
|
88
|
+
if not validated:
|
|
89
|
+
raise StoreIncompatible('adapter schema constraints must be validated')
|
|
90
|
+
# PostgreSQL 18 catalogs NOT NULL separately; attnotnull above checks
|
|
91
|
+
# the identical contract on both supported major versions.
|
|
92
|
+
if kind == 'n':
|
|
93
|
+
continue
|
|
94
|
+
actual_constraints[table].add(definition)
|
|
95
|
+
if actual_constraints != CONSTRAINTS:
|
|
96
|
+
raise StoreIncompatible('adapter schema constraints are incompatible')
|
|
97
|
+
version = await (await conn.execute('SELECT schema_version, storage_contract_version FROM prosaic_harness.schema_version')).fetchall()
|
|
98
|
+
if version != [(SCHEMA_VERSION, STORAGE_CONTRACT_VERSION)]:
|
|
99
|
+
raise StoreIncompatible('deploy a compatible adapter/schema; no automatic downgrade')
|
|
100
|
+
for table in COLUMNS:
|
|
101
|
+
privileges = ['SELECT']
|
|
102
|
+
if table in {'leases', 'runs', 'receipts'}:
|
|
103
|
+
privileges.append('INSERT')
|
|
104
|
+
if table in {'leases', 'runs'}:
|
|
105
|
+
privileges.append('UPDATE')
|
|
106
|
+
for privilege in privileges:
|
|
107
|
+
allowed = await (await conn.execute('SELECT pg_catalog.has_table_privilege(%s,%s)',
|
|
108
|
+
('prosaic_harness.' + table, privilege))).fetchone()
|
|
109
|
+
if not allowed or not allowed[0]:
|
|
110
|
+
raise StoreIncompatible('grant required runtime table privileges before accepting work')
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def check_ready(transport):
|
|
114
|
+
transport.call(ready_on_connection)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def initialize(transport):
|
|
118
|
+
async def migrate(conn):
|
|
119
|
+
await conn.execute("SELECT pg_catalog.set_config('lock_timeout','25000ms',true), "
|
|
120
|
+
"pg_catalog.set_config('statement_timeout','28000ms',true)")
|
|
121
|
+
await conn.execute('SELECT pg_catalog.pg_advisory_xact_lock(%s)', (MIGRATION_LOCK,))
|
|
122
|
+
namespace = await (await conn.execute("""SELECT n.oid,
|
|
123
|
+
pg_catalog.pg_has_role(n.nspowner,'USAGE') FROM pg_catalog.pg_namespace n
|
|
124
|
+
WHERE n.nspname='prosaic_harness'""")).fetchone()
|
|
125
|
+
if namespace:
|
|
126
|
+
if not namespace[1]:
|
|
127
|
+
raise StoreIncompatible('migration role must own the adapter schema')
|
|
128
|
+
count = await (await conn.execute('SELECT count(*) FROM pg_catalog.pg_class WHERE relnamespace=%s',
|
|
129
|
+
(namespace[0],))).fetchone()
|
|
130
|
+
if count[0]:
|
|
131
|
+
await ready_on_connection(conn)
|
|
132
|
+
return
|
|
133
|
+
await conn.execute(DDL)
|
|
134
|
+
await ready_on_connection(conn)
|
|
135
|
+
transport.call(migrate, timeout_s=30)
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Namespace-bound RunStore with short transactions and no inference retries."""
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from prosaic_harness import (RunSnapshot, StoreBusy, StoreCorrupt, StoreUnavailable,
|
|
11
|
+
LeaseLost, RunBusy, RunNotFound, RunAlreadyExists,
|
|
12
|
+
RevisionConflict, ReceiptConflict)
|
|
13
|
+
from prosaic_harness.run_store import (validate_namespace, validate_id, validate_revision,
|
|
14
|
+
encode_document, decode_document, revision_token)
|
|
15
|
+
from .transport import PostgresTransport, positive
|
|
16
|
+
from . import schema
|
|
17
|
+
from .leases import PostgresLease
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PostgresRunStore:
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_env(cls, name, *, namespace, **kwargs):
|
|
23
|
+
if not isinstance(name, str) or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,127}', name):
|
|
24
|
+
raise ValueError('dsn environment name must be a bounded identifier')
|
|
25
|
+
dsn = os.environ.get(name)
|
|
26
|
+
if not dsn:
|
|
27
|
+
raise StoreUnavailable('required database environment variable is missing')
|
|
28
|
+
return cls(dsn, namespace=namespace, **kwargs)
|
|
29
|
+
|
|
30
|
+
def __init__(self, dsn, *, namespace, lease_s=60, renew_s=15,
|
|
31
|
+
operation_timeout_s=10, pool_timeout_s=5, connect_timeout_s=5,
|
|
32
|
+
lock_timeout_s=1, statement_timeout_s=5, pool_size=8, max_active_leases=4):
|
|
33
|
+
self.namespace = validate_namespace(namespace)
|
|
34
|
+
positive(lease_s, 'lease_s')
|
|
35
|
+
positive(renew_s, 'renew_s')
|
|
36
|
+
positive(operation_timeout_s, 'operation_timeout_s')
|
|
37
|
+
if (lease_s < 15 or renew_s < 1 or renew_s > lease_s / 3
|
|
38
|
+
or renew_s + operation_timeout_s + 1 >= lease_s):
|
|
39
|
+
raise ValueError('lease timing requires duration >=15, renewal >=1 and adequate deadline margin')
|
|
40
|
+
self.lease_s, self.renew_s = lease_s, renew_s
|
|
41
|
+
self._pid = os.getpid()
|
|
42
|
+
self._mutex = threading.RLock()
|
|
43
|
+
self._sessions = set()
|
|
44
|
+
self._reservations = set()
|
|
45
|
+
self._max_active_leases = max_active_leases
|
|
46
|
+
self._transport = PostgresTransport(dsn, operation_timeout_s=operation_timeout_s,
|
|
47
|
+
pool_timeout_s=pool_timeout_s, connect_timeout_s=connect_timeout_s,
|
|
48
|
+
lock_timeout_s=lock_timeout_s, statement_timeout_s=statement_timeout_s,
|
|
49
|
+
pool_size=pool_size, max_active_leases=max_active_leases)
|
|
50
|
+
|
|
51
|
+
def initialize(self):
|
|
52
|
+
schema.initialize(self._transport)
|
|
53
|
+
|
|
54
|
+
def check_ready(self):
|
|
55
|
+
schema.check_ready(self._transport)
|
|
56
|
+
|
|
57
|
+
def _local(self, run_id, lease):
|
|
58
|
+
self._transport._check_process()
|
|
59
|
+
validate_id(run_id)
|
|
60
|
+
if (not isinstance(lease, PostgresLease) or lease.store is not self
|
|
61
|
+
or lease.run_id != run_id or lease not in self._sessions):
|
|
62
|
+
raise LeaseLost('lease does not own this store/run')
|
|
63
|
+
lease.check_valid()
|
|
64
|
+
|
|
65
|
+
async def _lease_row(self, conn, run_id):
|
|
66
|
+
return await (await conn.execute('SELECT owner,generation,expires_at FROM prosaic_harness.leases '
|
|
67
|
+
'WHERE namespace=%s AND run_id=%s FOR UPDATE', (self.namespace, run_id))).fetchone()
|
|
68
|
+
|
|
69
|
+
async def _guard_lease(self, conn, lease):
|
|
70
|
+
self._local(lease.run_id, lease)
|
|
71
|
+
row = await self._lease_row(conn, lease.run_id)
|
|
72
|
+
now = (await (await conn.execute('SELECT pg_catalog.clock_timestamp()')).fetchone())[0]
|
|
73
|
+
if (not row or row[0] != lease.owner or row[1] != lease.generation
|
|
74
|
+
or row[2] is None or row[2] <= now):
|
|
75
|
+
lease._lost = True
|
|
76
|
+
raise LeaseLost('database execution ownership expired or changed')
|
|
77
|
+
lease.check_valid()
|
|
78
|
+
return now
|
|
79
|
+
|
|
80
|
+
async def _acquire(self, conn, lease):
|
|
81
|
+
await conn.execute('INSERT INTO prosaic_harness.leases(namespace,run_id) VALUES(%s,%s) '
|
|
82
|
+
'ON CONFLICT DO NOTHING', (self.namespace, lease.run_id))
|
|
83
|
+
row = await self._lease_row(conn, lease.run_id)
|
|
84
|
+
now = (await (await conn.execute('SELECT pg_catalog.clock_timestamp()')).fetchone())[0]
|
|
85
|
+
if row[0] is not None and row[2] > now:
|
|
86
|
+
raise RunBusy('run is owned by another execution')
|
|
87
|
+
generation = row[1] + 1
|
|
88
|
+
await conn.execute('UPDATE prosaic_harness.leases SET owner=%s,generation=%s,expires_at=%s '
|
|
89
|
+
'WHERE namespace=%s AND run_id=%s',
|
|
90
|
+
(lease.owner, generation, now + timedelta(seconds=self.lease_s),
|
|
91
|
+
self.namespace, lease.run_id))
|
|
92
|
+
return generation
|
|
93
|
+
|
|
94
|
+
async def _renew(self, conn, lease):
|
|
95
|
+
now = await self._guard_lease(conn, lease)
|
|
96
|
+
cursor = await conn.execute('UPDATE prosaic_harness.leases SET expires_at=%s '
|
|
97
|
+
'WHERE namespace=%s AND run_id=%s AND owner=%s AND generation=%s',
|
|
98
|
+
(now + timedelta(seconds=self.lease_s), self.namespace, lease.run_id, lease.owner, lease.generation))
|
|
99
|
+
if cursor.rowcount != 1:
|
|
100
|
+
lease._lost = True
|
|
101
|
+
raise LeaseLost('database renewal ownership changed')
|
|
102
|
+
|
|
103
|
+
async def _release(self, conn, lease):
|
|
104
|
+
await conn.execute('UPDATE prosaic_harness.leases SET owner=NULL,expires_at=NULL '
|
|
105
|
+
'WHERE namespace=%s AND run_id=%s AND owner=%s AND generation=%s',
|
|
106
|
+
(self.namespace, lease.run_id, lease.owner, lease.generation))
|
|
107
|
+
|
|
108
|
+
@contextmanager
|
|
109
|
+
def lease(self, run_id):
|
|
110
|
+
self._transport._check_process()
|
|
111
|
+
validate_id(run_id)
|
|
112
|
+
reservation = object()
|
|
113
|
+
session = None
|
|
114
|
+
primary = None
|
|
115
|
+
try:
|
|
116
|
+
with self._mutex:
|
|
117
|
+
if len(self._reservations) >= self._max_active_leases:
|
|
118
|
+
raise StoreBusy('active execution capacity exhausted')
|
|
119
|
+
# One token owns capacity throughout setup, execution and
|
|
120
|
+
# teardown. Registration never transfers or decrements it.
|
|
121
|
+
self._reservations.add(reservation)
|
|
122
|
+
session = PostgresLease(self, run_id, time.monotonic())
|
|
123
|
+
session.generation = self._transport.call(lambda conn: self._acquire(conn, session))
|
|
124
|
+
session.check_valid()
|
|
125
|
+
with self._mutex:
|
|
126
|
+
self._sessions.add(session)
|
|
127
|
+
# Retain the coroutine even if submission is interrupted before it
|
|
128
|
+
# returns. stop() closes an unstarted runner on its owning loop or
|
|
129
|
+
# drains a started renewal before ownership can be released.
|
|
130
|
+
session._renewal = session.maintain()
|
|
131
|
+
self._transport.submit(session._renewal)
|
|
132
|
+
yield session
|
|
133
|
+
except BaseException as exc:
|
|
134
|
+
primary = exc
|
|
135
|
+
raise
|
|
136
|
+
finally:
|
|
137
|
+
try:
|
|
138
|
+
if session is not None and session.generation is not None:
|
|
139
|
+
if session._renewal is None:
|
|
140
|
+
session._stopped.set()
|
|
141
|
+
else:
|
|
142
|
+
session.stop()
|
|
143
|
+
self._transport.call(lambda conn: self._release(conn, session), renewal=True)
|
|
144
|
+
except BaseException:
|
|
145
|
+
session._lost = True
|
|
146
|
+
if primary is None:
|
|
147
|
+
raise
|
|
148
|
+
primary.add_note('database lease cleanup failed; reconcile ownership before retrying')
|
|
149
|
+
finally:
|
|
150
|
+
if session is not None:
|
|
151
|
+
session._active = False
|
|
152
|
+
with self._mutex:
|
|
153
|
+
if (session is None or session.generation is None
|
|
154
|
+
or session._stopped.is_set()):
|
|
155
|
+
self._reservations.discard(reservation)
|
|
156
|
+
if session is not None and session._stopped.is_set():
|
|
157
|
+
self._sessions.discard(session)
|
|
158
|
+
|
|
159
|
+
def _token(self, run_id, revision):
|
|
160
|
+
return revision_token('postgres', self.namespace, run_id, revision)
|
|
161
|
+
|
|
162
|
+
async def _read_run(self, conn, run_id, *, lock=False):
|
|
163
|
+
row = await (await conn.execute('SELECT revision,CASE WHEN octet_length(document)<=8388608 '
|
|
164
|
+
'THEN document ELSE NULL END FROM prosaic_harness.runs WHERE namespace=%s AND run_id=%s'
|
|
165
|
+
+ (' FOR UPDATE' if lock else ''), (self.namespace, run_id))).fetchone()
|
|
166
|
+
if not row:
|
|
167
|
+
raise RunNotFound('run document not found')
|
|
168
|
+
state = decode_document(row[1])
|
|
169
|
+
if state.get('run_id') != run_id:
|
|
170
|
+
raise StoreCorrupt('checkpoint identity does not match its address')
|
|
171
|
+
return RunSnapshot(state, self._token(run_id, row[0])), row[0]
|
|
172
|
+
|
|
173
|
+
def load_run(self, run_id):
|
|
174
|
+
validate_id(run_id)
|
|
175
|
+
async def read(conn):
|
|
176
|
+
return (await self._read_run(conn, run_id))[0]
|
|
177
|
+
return self._transport.call(read)
|
|
178
|
+
|
|
179
|
+
def _document(self, run_id, state):
|
|
180
|
+
content = encode_document(state)
|
|
181
|
+
if state.get('run_id') != run_id:
|
|
182
|
+
raise StoreCorrupt('checkpoint identity does not match its address')
|
|
183
|
+
return content.decode('utf-8')
|
|
184
|
+
|
|
185
|
+
def _mutate(self, run_id, lease, callback):
|
|
186
|
+
self._local(run_id, lease)
|
|
187
|
+
async def guarded(conn):
|
|
188
|
+
await self._guard_lease(conn, lease)
|
|
189
|
+
return await callback(conn)
|
|
190
|
+
try:
|
|
191
|
+
result = self._transport.call(guarded)
|
|
192
|
+
lease.check_valid()
|
|
193
|
+
return result
|
|
194
|
+
except StoreUnavailable:
|
|
195
|
+
lease._lost = True
|
|
196
|
+
raise
|
|
197
|
+
|
|
198
|
+
def create_run(self, run_id, state, lease):
|
|
199
|
+
self._local(run_id, lease)
|
|
200
|
+
document = self._document(run_id, state)
|
|
201
|
+
async def create(conn):
|
|
202
|
+
row = await (await conn.execute('INSERT INTO prosaic_harness.runs(namespace,run_id,revision,document) '
|
|
203
|
+
'VALUES(%s,%s,1,%s) ON CONFLICT DO NOTHING RETURNING revision',
|
|
204
|
+
(self.namespace, run_id, document))).fetchone()
|
|
205
|
+
if not row:
|
|
206
|
+
raise RunAlreadyExists('run already exists; use resume or a new identity')
|
|
207
|
+
return self._token(run_id, row[0])
|
|
208
|
+
return self._mutate(run_id, lease, create)
|
|
209
|
+
|
|
210
|
+
def save_run(self, run_id, state, expected_revision, lease):
|
|
211
|
+
self._local(run_id, lease)
|
|
212
|
+
validate_revision(expected_revision)
|
|
213
|
+
document = self._document(run_id, state)
|
|
214
|
+
async def save(conn):
|
|
215
|
+
snapshot, revision = await self._read_run(conn, run_id, lock=True)
|
|
216
|
+
if snapshot.revision != expected_revision:
|
|
217
|
+
raise RevisionConflict('checkpoint changed; refresh status')
|
|
218
|
+
row = await (await conn.execute('UPDATE prosaic_harness.runs SET document=%s,revision=revision+1 '
|
|
219
|
+
'WHERE namespace=%s AND run_id=%s AND revision=%s RETURNING revision',
|
|
220
|
+
(document, self.namespace, run_id, revision))).fetchone()
|
|
221
|
+
if not row:
|
|
222
|
+
raise RevisionConflict('checkpoint changed; refresh status')
|
|
223
|
+
return self._token(run_id, row[0])
|
|
224
|
+
return self._mutate(run_id, lease, save)
|
|
225
|
+
|
|
226
|
+
async def _read_receipt(self, conn, run_id, invocation_id):
|
|
227
|
+
row = await (await conn.execute('SELECT CASE WHEN octet_length(document)<=8388608 THEN document '
|
|
228
|
+
'ELSE NULL END,sha256 FROM prosaic_harness.receipts WHERE namespace=%s AND run_id=%s AND invocation_id=%s',
|
|
229
|
+
(self.namespace, run_id, invocation_id))).fetchone()
|
|
230
|
+
if row is None:
|
|
231
|
+
return None
|
|
232
|
+
receipt = decode_document(row[0])
|
|
233
|
+
if (receipt.get('id') != invocation_id or receipt.get('run_id') != run_id
|
|
234
|
+
or hashlib.sha256(encode_document(receipt)).hexdigest() != row[1]):
|
|
235
|
+
raise StoreCorrupt('receipt integrity or identity does not match its address')
|
|
236
|
+
return receipt
|
|
237
|
+
|
|
238
|
+
def load_receipt(self, run_id, invocation_id):
|
|
239
|
+
validate_id(run_id)
|
|
240
|
+
validate_id(invocation_id)
|
|
241
|
+
return self._transport.call(lambda conn: self._read_receipt(conn, run_id, invocation_id))
|
|
242
|
+
|
|
243
|
+
def save_receipt(self, run_id, invocation_id, receipt, lease):
|
|
244
|
+
self._local(run_id, lease)
|
|
245
|
+
validate_id(invocation_id)
|
|
246
|
+
content = encode_document(receipt)
|
|
247
|
+
if receipt.get('id') != invocation_id or receipt.get('run_id') != run_id:
|
|
248
|
+
raise StoreCorrupt('receipt identity does not match its address')
|
|
249
|
+
async def save(conn):
|
|
250
|
+
await self._read_run(conn, run_id, lock=True)
|
|
251
|
+
await conn.execute('INSERT INTO prosaic_harness.receipts(namespace,run_id,invocation_id,document,sha256) '
|
|
252
|
+
'VALUES(%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING',
|
|
253
|
+
(self.namespace, run_id, invocation_id, content.decode('utf-8'), hashlib.sha256(content).hexdigest()))
|
|
254
|
+
existing = await self._read_receipt(conn, run_id, invocation_id)
|
|
255
|
+
if encode_document(existing) != content:
|
|
256
|
+
raise ReceiptConflict('immutable receipt differs from committed evidence')
|
|
257
|
+
self._mutate(run_id, lease, save)
|
|
258
|
+
|
|
259
|
+
def close(self):
|
|
260
|
+
if self._pid != os.getpid():
|
|
261
|
+
raise StoreBusy('construct database stores after worker fork')
|
|
262
|
+
with self._mutex:
|
|
263
|
+
if self._sessions or self._reservations:
|
|
264
|
+
raise StoreBusy('drain active executions before closing the store')
|
|
265
|
+
self._transport.close()
|
|
266
|
+
|
|
267
|
+
def __enter__(self):
|
|
268
|
+
self._transport._check_process()
|
|
269
|
+
return self
|
|
270
|
+
|
|
271
|
+
def __exit__(self, exc_type, exc, traceback):
|
|
272
|
+
try:
|
|
273
|
+
self.close()
|
|
274
|
+
except Exception:
|
|
275
|
+
if exc is None:
|
|
276
|
+
raise
|
|
277
|
+
exc.add_note('database store cleanup failed; drain pending operations')
|