unified_store.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. """One durable store: the existing Parse PostgreSQL database.
  2. Original rows live in typed PostgreSQL tables, not SQLite files or archive blobs.
  3. An in-memory SQLite connection preserves the established FTS5/query contracts.
  4. It is a disposable execution projection; there is no disk cache or fallback.
  5. Monitoring writes hold a PostgreSQL advisory lock until commit/rollback and use
  6. the same validated legacy rules. Research generations publish by atomic CAS.
  7. """
  8. import base64
  9. from contextlib import contextmanager
  10. from contextvars import ContextVar
  11. import hashlib
  12. import json
  13. import os
  14. from pathlib import Path
  15. import re
  16. import sqlite3
  17. import threading
  18. import time
  19. VERSION = 'ksf.unified-db.v1'
  20. DATABASE = 'FKieRbIoGtmaster'
  21. ROLE = 'FKieRbIoGtadmin'
  22. CONTROL = 'ksf_voc_control'
  23. _CACHE = {}
  24. _LOCK = threading.RLock()
  25. _BINDING = ContextVar('ksf_unified_binding', default=None)
  26. class StoreError(ValueError):
  27. pass
  28. def need(test, code):
  29. if not test:
  30. raise StoreError(code)
  31. def canonical(value):
  32. return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':'), allow_nan=False)
  33. def sha(value):
  34. return hashlib.sha256(value if isinstance(value, bytes) else value.encode('utf-8')).hexdigest()
  35. def configured():
  36. return _BINDING.get() or os.environ.get('KSF_UNIFIED_DB_CONFIG')
  37. @contextmanager
  38. def binding(path=None):
  39. token = _BINDING.set(path or configured())
  40. try:
  41. yield
  42. finally:
  43. _BINDING.reset(token)
  44. def configuration(path=None):
  45. path = Path(path or configured() or '').absolute()
  46. need(path.is_file() and not path.is_symlink(), 'UNIFIED_CONFIG_REQUIRED')
  47. if os.name != 'nt':
  48. need(path.stat().st_mode & 0o077 == 0, 'UNIFIED_CONFIG_NOT_PRIVATE')
  49. value = json.loads(path.read_text('utf-8-sig'))
  50. need(set(value) == {'schema', 'appId', 'database', 'connection'} and
  51. value['schema'] == VERSION and value['appId'] == 'FKieRbIoGt_main' and
  52. value['database'] == DATABASE, 'UNIFIED_TARGET_MISMATCH')
  53. conn = value['connection']
  54. allowed = {'service', 'host', 'port', 'user', 'passfile', 'sslmode', 'sslrootcert', 'connect_timeout'}
  55. need(isinstance(conn, dict) and conn and not set(conn) - allowed, 'INVALID_UNIFIED_CONNECTION')
  56. need('password' not in conn, 'USE_EXISTING_PRIVATE_PASSFILE')
  57. for key in ('passfile', 'sslrootcert'):
  58. if key in conn:
  59. p = Path(conn[key])
  60. need(p.is_absolute() and p.is_file() and not p.is_symlink(), 'UNIFIED_CONNECTION_FILE_REQUIRED')
  61. if key == 'passfile' and os.name != 'nt':
  62. need(p.stat().st_mode & 0o077 == 0, 'UNIFIED_PASSFILE_NOT_PRIVATE')
  63. return value
  64. def connect_pg(path=None, readonly=False):
  65. pg = None
  66. try:
  67. import psycopg
  68. config = configuration(path)
  69. pg = psycopg.connect(dbname=DATABASE, application_name='ksf-unified-library',
  70. **config['connection'])
  71. row = pg.execute('SELECT current_database(),current_user').fetchone()
  72. need(row == (DATABASE, ROLE), 'UNIFIED_TARGET_IDENTITY_MISMATCH')
  73. pg.commit()
  74. pg.execute('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY' if readonly else 'BEGIN')
  75. pg.execute("SET LOCAL statement_timeout='120s'")
  76. pg.execute("SET LOCAL lock_timeout='20s'")
  77. return pg
  78. except StoreError:
  79. if pg is not None:
  80. pg.close()
  81. raise
  82. except Exception:
  83. if pg is not None:
  84. pg.close()
  85. raise StoreError('UNIFIED_CONNECTION_FAILED') from None
  86. def identifier(value):
  87. need(isinstance(value, str) and re.fullmatch('[a-z_][a-z0-9_]{0,62}', value), 'INVALID_STORAGE_IDENTIFIER')
  88. return '"' + value + '"'
  89. def ensure_control(pg):
  90. pg.execute('CREATE SCHEMA IF NOT EXISTS ' + CONTROL)
  91. pg.execute('''CREATE TABLE IF NOT EXISTS ksf_voc_control.datasets(
  92. dataset text PRIMARY KEY CHECK(dataset IN ('research','monitoring')),
  93. generation text NOT NULL, revision bigint NOT NULL, storage_schema text NOT NULL,
  94. manifest jsonb NOT NULL, pointer jsonb, updated_at timestamptz NOT NULL DEFAULT now())''')
  95. pg.execute('''CREATE TABLE IF NOT EXISTS ksf_voc_control.snapshots(
  96. dataset text NOT NULL, generation text NOT NULL, revision bigint NOT NULL,
  97. storage_schema text UNIQUE NOT NULL, manifest jsonb NOT NULL, pointer jsonb,
  98. created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(dataset,generation,revision))''')
  99. # Default grants are deliberately absent; existing Parse classes/ACLs are untouched.
  100. pg.execute('REVOKE ALL ON SCHEMA ksf_voc_control FROM PUBLIC')
  101. def lock_dataset(pg, dataset):
  102. need(dataset in ('research', 'monitoring'), 'UNKNOWN_DATASET')
  103. pg.execute('SELECT pg_advisory_xact_lock(hashtext(%s))', ('ksf-unified:' + dataset,))
  104. def descriptor(pg, dataset):
  105. row = pg.execute('SELECT generation,revision,storage_schema,manifest,pointer FROM '
  106. 'ksf_voc_control.datasets WHERE dataset=%s', (dataset,)).fetchone()
  107. need(row is not None, 'UNIFIED_DATASET_NOT_INSTALLED')
  108. return dict(zip(('generation', 'revision', 'storage_schema', 'manifest', 'pointer'), row))
  109. def packed(value):
  110. return {'$bytes': base64.b64encode(value).decode('ascii')} if isinstance(value, bytes) else value
  111. def row_hash(rows):
  112. digest = hashlib.sha256()
  113. for row in rows:
  114. digest.update(canonical([packed(v) for v in row]).encode('utf-8') + b'\n')
  115. return digest.hexdigest()
  116. def inventory(c):
  117. """Store semantic tables and their original definitions; rebuild FTS internals."""
  118. objects = [dict(zip(('type', 'name', 'tbl_name', 'sql'), r)) for r in c.execute(
  119. "SELECT type,name,tbl_name,sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type,name")]
  120. virtual = {r['name'] for r in objects if r['type'] == 'table' and
  121. re.match(r'CREATE\s+VIRTUAL\s+TABLE', r['sql'], re.I)}
  122. tables = []
  123. for obj in objects:
  124. name = obj['name']
  125. if obj['type'] != 'table' or name.startswith('sqlite_') or any(
  126. name == v or name.startswith(v + '_') for v in virtual):
  127. continue
  128. identifier(name)
  129. cols = [dict(zip(('cid', 'name', 'type', 'notnull', 'dflt_value', 'pk'), row))
  130. for row in c.execute('PRAGMA table_info(' + identifier(name) + ')')]
  131. need(cols and all(re.fullmatch('[a-z_][a-z0-9_]*', x['name']) for x in cols), 'INVALID_SOURCE_COLUMNS')
  132. pk = [x['name'] for x in sorted(cols, key=lambda x: x['pk']) if x['pk']]
  133. order = pk or [x['name'] for x in cols]
  134. has_rowid = not re.search(r'\bWITHOUT\s+ROWID\b', obj['sql'], re.I)
  135. need(not any(x['name'] in ('_rowid_', '_ksf_source_rowid') for x in cols), 'RESERVED_SOURCE_COLUMN')
  136. rows = list(c.execute('SELECT ' + ','.join(identifier(x['name']) for x in cols) +
  137. ' FROM ' + identifier(name) + ' ORDER BY ' + ','.join(map(identifier, order))))
  138. indexes = []
  139. for r in c.execute('PRAGMA index_list(' + identifier(name) + ')'):
  140. fields = [x[2] for x in c.execute('PRAGMA index_info(' + identifier(r[1]) + ')')]
  141. if r[2] and all(fields):
  142. indexes.append(fields)
  143. tables.append({'name': name, 'columns': cols, 'primaryKey': pk, 'order': order,
  144. 'unique': indexes, 'count': len(rows), 'sha256': row_hash(rows),
  145. 'hasRowid': has_rowid, 'rowidSha256': row_hash(c.execute('SELECT _rowid_ FROM ' +
  146. identifier(name) + ' ORDER BY ' + ','.join(map(identifier, order)))) if has_rowid else None})
  147. names = {t['name'] for t in tables}
  148. definitions = [o for o in objects if o['name'] in names or o['tbl_name'] in names or o['name'] in virtual]
  149. # Shadow tables cannot be recreated alongside their virtual table.
  150. definitions = [o for o in definitions if not any(o['name'].startswith(v + '_') for v in virtual)]
  151. result = {'schema': VERSION, 'tables': tables, 'definitions': definitions, 'virtualTables': sorted(virtual),
  152. 'analyzed': bool(c.execute("SELECT 1 FROM sqlite_master WHERE name='sqlite_stat1'").fetchone())}
  153. result['fingerprint'] = sha(canonical(result))
  154. return result
  155. def compare_manifest(before, after):
  156. need(before['fingerprint'] == after['fingerprint'], 'UNIFIED_FULL_ROW_VERIFICATION_FAILED')
  157. def pg_type(field):
  158. kind = field['type'].upper()
  159. if 'INT' in kind:
  160. return 'bigint'
  161. if any(s in kind for s in ('REAL', 'FLOA', 'DOUB')):
  162. return 'double precision'
  163. if 'BLOB' in kind:
  164. return 'bytea'
  165. need(any(s in kind for s in ('TEXT', 'CHAR', 'CLOB')), 'UNSUPPORTED_SOURCE_TYPE')
  166. return 'text COLLATE "C"'
  167. def upload_tables(pg, storage, manifest, c, previous=None):
  168. old = {t['name']: t for t in (previous or {}).get('tables', [])}
  169. need(not set(old) - {t['name'] for t in manifest['tables']}, 'MONITORING_TABLE_REMOVAL_REJECTED')
  170. for table in manifest['tables']:
  171. name = table['name']
  172. if name in old and old[name] == table:
  173. continue
  174. full = identifier(storage) + '.' + identifier(name)
  175. definitions = [identifier(f['name']) + ' ' + pg_type(f) + (' NOT NULL' if f['notnull'] else '')
  176. for f in table['columns']]
  177. if table['hasRowid']:
  178. definitions.append('"_ksf_source_rowid" bigint NOT NULL UNIQUE')
  179. if table['primaryKey']:
  180. definitions.append('PRIMARY KEY (' + ','.join(map(identifier, table['primaryKey'])) + ')')
  181. if name in old:
  182. need(old[name]['columns'] == table['columns'], 'MONITORING_COLUMN_CHANGE_REJECTED')
  183. pg.execute('TRUNCATE TABLE ' + full)
  184. else:
  185. pg.execute('CREATE TABLE ' + full + '(' + ','.join(definitions) + ')')
  186. fields = ','.join(identifier(f['name']) for f in table['columns'])
  187. query = 'SELECT ' + fields + (',_rowid_' if table['hasRowid'] else '') + ' FROM ' + identifier(name) + ' ORDER BY ' + ','.join(map(identifier, table['order']))
  188. with pg.cursor().copy('COPY ' + full + '(' + fields + (',"_ksf_source_rowid"' if table['hasRowid'] else '') + ') FROM STDIN') as copy:
  189. for row in c.execute(query):
  190. copy.write_row(tuple(row))
  191. for idx, columns in enumerate(table['unique']):
  192. if name not in old and columns != table['primaryKey']:
  193. pg.execute('CREATE UNIQUE INDEX ON ' + full + '(' + ','.join(map(identifier, columns)) + ')')
  194. # Independent PG read of EVERY field and row, not just a count.
  195. rows = pg.execute('SELECT ' + fields + ' FROM ' + full + ' ORDER BY ' +
  196. ','.join(map(identifier, table['order']))).fetchall()
  197. need(len(rows) == table['count'] and row_hash(rows) == table['sha256'], 'POSTGRES_ROW_HASH_MISMATCH')
  198. if table['hasRowid']:
  199. need(row_hash(pg.execute('SELECT "_ksf_source_rowid" FROM ' + full + ' ORDER BY ' +
  200. ','.join(map(identifier, table['order'])))) == table['rowidSha256'], 'POSTGRES_ROWID_HASH_MISMATCH')
  201. def stage_snapshot(pg, dataset, generation, revision, manifest, c, pointer=None):
  202. """Add a sealed generation in the SAME PostgreSQL database, never CREATE DATABASE."""
  203. storage = 'ksfv_' + sha(dataset + ':' + generation + ':' + str(revision) + ':' + manifest['fingerprint'])[:40]
  204. need(not pg.execute('SELECT 1 FROM pg_namespace WHERE nspname=%s', (storage,)).fetchone(),
  205. 'SNAPSHOT_SCHEMA_ALREADY_EXISTS')
  206. pg.execute('CREATE SCHEMA ' + identifier(storage))
  207. pg.execute('REVOKE ALL ON SCHEMA ' + identifier(storage) + ' FROM PUBLIC')
  208. upload_tables(pg, storage, manifest, c)
  209. pg.execute('INSERT INTO ksf_voc_control.snapshots(dataset,generation,revision,storage_schema,manifest,pointer) '
  210. 'VALUES(%s,%s,%s,%s,%s::jsonb,%s::jsonb)',
  211. (dataset, generation, revision, storage, canonical(manifest), canonical(pointer)))
  212. return storage
  213. def reconstruct(pg, desc):
  214. """Ephemeral RAM projection; never connects to a SQLite filename."""
  215. manifest = desc['manifest']
  216. c = sqlite3.connect(':memory:', check_same_thread=False)
  217. try:
  218. c.execute('PRAGMA foreign_keys=OFF')
  219. definitions = manifest['definitions']
  220. virtual = manifest['virtualTables']
  221. for obj in definitions:
  222. if obj['type'] == 'table' and obj['name'] not in virtual:
  223. c.execute(obj['sql'])
  224. for table in manifest['tables']:
  225. fields = ','.join(identifier(f['name']) for f in table['columns'])
  226. extra = ',"_ksf_source_rowid"' if table['hasRowid'] else ''
  227. sql = 'SELECT ' + fields + extra + ' FROM ' + identifier(desc['storage_schema']) + '.' + identifier(table['name'])
  228. rows = pg.execute(sql + ' ORDER BY ' + ','.join(map(identifier, table['order']))).fetchall()
  229. original = [r[:-1] for r in rows] if table['hasRowid'] else rows
  230. need(len(rows) == table['count'] and row_hash(original) == table['sha256'], 'UNIFIED_ROW_CONTENT_CHANGED')
  231. if table['hasRowid']:
  232. need(row_hash((r[-1],) for r in rows) == table['rowidSha256'], 'UNIFIED_ROWID_CHANGED')
  233. c.executemany('INSERT INTO ' + identifier(table['name']) + '(' + fields +
  234. (',_rowid_' if table['hasRowid'] else '') + ') VALUES(' +
  235. ','.join('?' for _ in range(len(table['columns']) + int(table['hasRowid']))) + ')', rows)
  236. for obj in definitions:
  237. if obj['type'] in ('index', 'view', 'trigger') or obj['name'] in virtual:
  238. c.execute(obj['sql'])
  239. for name in virtual:
  240. need(name == 'retrieval_fts', 'UNSUPPORTED_VIRTUAL_TABLE')
  241. c.execute('INSERT INTO retrieval_fts(retrieval_fts) VALUES(\'rebuild\')')
  242. if manifest.get('analyzed'):
  243. c.execute('ANALYZE')
  244. c.commit()
  245. need(c.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' and
  246. not c.execute('PRAGMA foreign_key_check').fetchall(), 'UNIFIED_PROJECTION_INTEGRITY_FAILED')
  247. compare_manifest(manifest, inventory(c))
  248. return c
  249. except BaseException:
  250. c.close()
  251. raise
  252. def pointer(path=None, dataset='research'):
  253. with connect_pg(path, readonly=True) as pg:
  254. desc = descriptor(pg, dataset)
  255. return desc['pointer'] or {'generation': desc['generation']}
  256. def research_reference(path=None):
  257. return Reference(path or configured())
  258. class Reference:
  259. def __init__(self, path):
  260. need(bool(path), 'UNIFIED_CONFIG_REQUIRED')
  261. self.path = str(Path(path).absolute())
  262. @contextmanager
  263. def read_research(ref):
  264. # Requests are serial within a worker; PostgreSQL readers still use snapshots.
  265. with _LOCK:
  266. with connect_pg(ref.path, readonly=True) as pg:
  267. desc = descriptor(pg, 'research')
  268. key = (ref.path, desc['generation'], desc['revision'])
  269. if key not in _CACHE:
  270. fresh = reconstruct(pg, desc)
  271. for old in list(_CACHE):
  272. if old[0] == ref.path:
  273. _CACHE.pop(old).close()
  274. fresh.row_factory = sqlite3.Row
  275. fresh.execute('PRAGMA query_only=ON')
  276. _CACHE[key] = fresh
  277. c = _CACHE[key]
  278. c.execute('BEGIN')
  279. try:
  280. yield c
  281. finally:
  282. c.rollback()
  283. def migrate(source, config, dataset='research', registry_pointer=None, apply=False, expected=None):
  284. source = Path(source).resolve()
  285. need(source.is_file(), 'MIGRATION_SOURCE_MISSING')
  286. # Source snapshot is read transactionally; WAL is included by SQLite backup, not file copy.
  287. raw = sqlite3.connect(source.as_uri() + '?mode=ro', uri=True)
  288. c = sqlite3.connect(':memory:')
  289. try:
  290. raw.backup(c)
  291. raw.close()
  292. need(c.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' and
  293. not c.execute('PRAGMA foreign_key_check').fetchall(), 'MIGRATION_SOURCE_INTEGRITY_FAILED')
  294. manifest = inventory(c)
  295. meta = dict(c.execute('SELECT key,value FROM meta'))
  296. generation = meta.get('generation') if dataset == 'research' else manifest['fingerprint']
  297. need(bool(generation), 'SOURCE_GENERATION_MISSING')
  298. if dataset == 'research':
  299. need(meta.get('status') == 'ready' and meta.get('schema') == 'ksf.voc-library.v2', 'SOURCE_NOT_READY')
  300. if registry_pointer:
  301. need(registry_pointer.get('generation') == generation, 'SOURCE_POINTER_GENERATION_MISMATCH')
  302. result = {'ok': True, 'schema': VERSION, 'dataset': dataset, 'generation': generation,
  303. 'fingerprint': manifest['fingerprint'], 'tables': [{k: t[k] for k in ('name', 'count', 'sha256')}
  304. for t in manifest['tables']], 'readOnly': not apply, 'database': DATABASE,
  305. 'applied': False, 'sourceModified': False, 'businessRowsChanged': 0}
  306. with connect_pg(config, readonly=not apply) as pg:
  307. control_exists = pg.execute('SELECT 1 FROM pg_namespace WHERE nspname=%s', (CONTROL,)).fetchone()
  308. prior = None
  309. if control_exists:
  310. row = pg.execute('SELECT generation,manifest FROM ksf_voc_control.datasets WHERE dataset=%s', (dataset,)).fetchone()
  311. if row:
  312. prior = {'generation': row[0], 'manifest': row[1]}
  313. if prior and prior['generation'] == generation:
  314. compare_manifest(prior['manifest'], manifest)
  315. result['operation'] = 'already-migrated'
  316. # Do not claim a successful retry from metadata alone.
  317. check = reconstruct(pg, descriptor(pg, dataset))
  318. check.close()
  319. return result
  320. need(expected is None or (prior or {}).get('generation') == expected, 'UNIFIED_GENERATION_CHANGED')
  321. need(not prior or expected is not None, 'EXISTING_DATASET_REQUIRES_EXPECTED_GENERATION')
  322. if not apply:
  323. result['operation'] = 'plan'
  324. return result
  325. ensure_control(pg)
  326. lock_dataset(pg, dataset)
  327. current = pg.execute('SELECT generation FROM ksf_voc_control.datasets WHERE dataset=%s', (dataset,)).fetchone()
  328. need((current[0] if current else None) == (prior or {}).get('generation'), 'UNIFIED_CONCURRENT_CHANGE')
  329. revision = 1
  330. storage = stage_snapshot(pg, dataset, generation, revision, manifest, c, registry_pointer)
  331. pg.execute('INSERT INTO ksf_voc_control.datasets(dataset,generation,revision,storage_schema,manifest,pointer) '
  332. 'VALUES(%s,%s,%s,%s,%s::jsonb,%s::jsonb) ON CONFLICT(dataset) DO UPDATE SET '
  333. 'generation=EXCLUDED.generation,revision=EXCLUDED.revision,storage_schema=EXCLUDED.storage_schema,'
  334. 'manifest=EXCLUDED.manifest,pointer=EXCLUDED.pointer,updated_at=now()',
  335. (dataset, generation, revision, storage, canonical(manifest), canonical(registry_pointer)))
  336. result.update(applied=True, operation='migrated', allRowsVerified=True)
  337. return result
  338. finally:
  339. raw.close()
  340. c.close()
  341. def archive_research(source, config, entry, apply=False):
  342. """Retain an existing legacy rollback target inside PostgreSQL without activating it."""
  343. source = Path(source).resolve()
  344. need(source.is_file() and sha(source.read_bytes()) == entry.get('databaseSha256'), 'HISTORICAL_SOURCE_HASH_MISMATCH')
  345. raw = sqlite3.connect(source.as_uri() + '?mode=ro', uri=True)
  346. c = sqlite3.connect(':memory:')
  347. try:
  348. raw.backup(c)
  349. meta = dict(c.execute('SELECT key,value FROM meta'))
  350. need(meta.get('generation') == entry.get('generation') and meta.get('status') == 'ready', 'HISTORICAL_GENERATION_MISMATCH')
  351. need(c.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' and
  352. not c.execute('PRAGMA foreign_key_check').fetchall(), 'HISTORICAL_SOURCE_INTEGRITY_FAILED')
  353. manifest = inventory(c)
  354. result = {'generation': meta['generation'], 'fingerprint': manifest['fingerprint'],
  355. 'tables': len(manifest['tables']), 'rows': sum(t['count'] for t in manifest['tables']),
  356. 'readOnly': not apply, 'activated': False}
  357. with connect_pg(config, readonly=not apply) as pg:
  358. exists = pg.execute('SELECT 1 FROM pg_namespace WHERE nspname=%s', (CONTROL,)).fetchone()
  359. if not apply:
  360. return result
  361. need(exists, 'MIGRATE_CURRENT_GENERATION_FIRST')
  362. lock_dataset(pg, 'research')
  363. row = pg.execute('SELECT storage_schema,manifest FROM ksf_voc_control.snapshots '
  364. 'WHERE dataset=\'research\' AND generation=%s AND revision=1', (meta['generation'],)).fetchone()
  365. if row:
  366. compare_manifest(row[1], manifest)
  367. verified = reconstruct(pg, {'storage_schema': row[0], 'manifest': row[1]});verified.close()
  368. else:
  369. saved_pointer = {**entry, 'history': entry.get('history', [entry])}
  370. stage_snapshot(pg, 'research', meta['generation'], 1, manifest, c, saved_pointer)
  371. result['allRowsVerified'] = True
  372. return result
  373. finally:
  374. raw.close();c.close()
  375. class MonitoringConnection:
  376. """Legacy write transaction, serialized before reading its PostgreSQL baseline."""
  377. def __init__(self, config=None, write=True):
  378. self.config = config or configured()
  379. self.pg = connect_pg(self.config, readonly=not write)
  380. self.write = write
  381. self.closed = False
  382. try:
  383. if write:
  384. lock_dataset(self.pg, 'monitoring')
  385. self.desc = descriptor(self.pg, 'monitoring')
  386. self.c = reconstruct(self.pg, self.desc)
  387. if not write:
  388. self.c.execute('PRAGMA query_only=ON')
  389. except BaseException:
  390. self.pg.close()
  391. raise
  392. @property
  393. def row_factory(self):
  394. return self.c.row_factory
  395. @row_factory.setter
  396. def row_factory(self, value):
  397. self.c.row_factory = value
  398. def __getattr__(self, key):
  399. return getattr(self.c, key)
  400. def commit(self):
  401. need(self.write and not self.closed, 'UNIFIED_READ_ONLY')
  402. self.c.commit()
  403. need(not self.c.execute('PRAGMA foreign_key_check').fetchall(), 'MONITORING_FOREIGN_KEY_FAILED')
  404. manifest = inventory(self.c)
  405. if manifest['fingerprint'] != self.desc['manifest']['fingerprint']:
  406. revision = self.desc['revision'] + 1
  407. storage = 'ksfv_monitoring_live'
  408. exists = self.pg.execute('SELECT 1 FROM pg_namespace WHERE nspname=%s', (storage,)).fetchone()
  409. if not exists:
  410. self.pg.execute('CREATE SCHEMA ' + identifier(storage))
  411. self.pg.execute('REVOKE ALL ON SCHEMA ' + identifier(storage) + ' FROM PUBLIC')
  412. upload_tables(self.pg, storage, manifest, self.c,
  413. self.desc['manifest'] if self.desc['storage_schema'] == storage else None)
  414. self.pg.execute('UPDATE ksf_voc_control.datasets SET revision=%s,storage_schema=%s,manifest=%s::jsonb,'
  415. 'updated_at=now() WHERE dataset=\'monitoring\' AND revision=%s',
  416. (revision, storage, canonical(manifest), self.desc['revision']))
  417. self.pg.commit()
  418. self.closed = True
  419. def rollback(self):
  420. self.c.rollback()
  421. self.pg.rollback()
  422. self.closed = True
  423. def close(self):
  424. self.c.close()
  425. self.pg.close()
  426. def monitoring_available(root):
  427. if configured():
  428. with connect_pg(readonly=True) as pg:
  429. descriptor(pg, 'monitoring')
  430. return True
  431. return bool(root) and (Path(root) / 'monitoring.sqlite').is_file()
  432. def monitoring_connection(root, write=True, timeout=20):
  433. if configured():
  434. return MonitoringConnection(write=write)
  435. p = Path(root).resolve() / 'monitoring.sqlite'
  436. return sqlite3.connect(str(p) if write else p.as_uri() + '?mode=ro', uri=not write, timeout=timeout)
  437. def publish(incoming, registry, apply=False, label='资料更新', rollback=None, expected_generation=None, review_correction=None):
  438. """Existing merge rules build only a disposable stage; publish in PostgreSQL."""
  439. import tempfile
  440. import update_library
  441. config = configured()
  442. need(config, 'UNIFIED_CONFIG_REQUIRED')
  443. if rollback:
  444. need(review_correction is None, 'CORRECTION_ROLLBACK_NOT_ALLOWED')
  445. return rollback_research(config, rollback, expected_generation, apply)
  446. with tempfile.TemporaryDirectory(prefix='ksf-unified-merge-') as td:
  447. baseline, target = Path(td) / 'baseline.sqlite', Path(td) / 'merged.sqlite'
  448. with connect_pg(config, readonly=True) as pg:
  449. desc = descriptor(pg, 'research')
  450. need(expected_generation is not None and desc['generation'] == expected_generation, 'LIBRARY_GENERATION_CHANGED')
  451. prior = desc['pointer'] or {'generation': desc['generation'], 'history': []}
  452. meta = update_library.verified(Path(incoming))
  453. if any(x.get('incomingGeneration') == meta['generation'] for x in prior.get('history', [])):
  454. return {'ok': True, 'applied': False, 'readOnly': not apply, 'operation': 'already-imported',
  455. 'generation': desc['generation'], 'currentGeneration': desc['generation'],
  456. 'plannedGeneration': desc['generation'], 'incomingGeneration': meta['generation'],
  457. 'plannedCounts': {t['name']: t['count'] for t in desc['manifest']['tables']},
  458. 'temporaryFilesUsed': False, 'persistentFilesWritten': False,
  459. 'activeLibraryModified': False, 'databaseWrites': 0}
  460. c = reconstruct(pg, desc)
  461. disk = sqlite3.connect(baseline)
  462. c.backup(disk)
  463. disk.close()
  464. c.close()
  465. delta = update_library.merge(baseline, Path(incoming), target, review_correction)
  466. final = update_library.verified(target)
  467. entry = {'generation': final['generation'], 'incomingGeneration': meta['generation'],
  468. 'delta': delta, 'label': label, 'updatedAt': __import__('datetime').datetime.now(
  469. __import__('datetime').timezone.utc).isoformat(), 'storage': 'parse-postgresql'}
  470. pointer_value = {**entry, 'history': [entry, *prior.get('history', [])]}
  471. result = migrate(target, config, registry_pointer=pointer_value, apply=apply, expected=expected_generation)
  472. with update_library.voc_db.readonly(target) as c:
  473. counts = update_library.voc_db.table_counts(c)
  474. rows = [dict(r) for r in c.execute('SELECT entity,record_id,decision,reason_codes_json,prior_source_record_id,incoming_source_record_id,selected_source_record_id FROM merge_decisions WHERE batch_sha256=? ORDER BY entity,record_id LIMIT 101', (meta['archive_sha256'],))]
  475. for row in rows:
  476. row['reasons'] = json.loads(row.pop('reason_codes_json'))
  477. return {**result, **(pointer_value if apply else {}), 'delta': delta,
  478. 'incomingGeneration': meta['generation'], 'currentGeneration': expected_generation,
  479. 'plannedGeneration': final['generation'], 'plannedCounts': counts,
  480. 'recordDecisions': rows[:100], 'recordDecisionsTruncated': len(rows) > 100,
  481. 'databaseWrites': 'postgresql-new-generation' if apply else 'temporary-only',
  482. 'activeLibraryModified': bool(result['applied']), 'temporaryFilesUsed': True,
  483. 'persistentFilesWritten': bool(result['applied']), 'persistentSqliteFilesWritten': False}
  484. def rollback_research(config, generation, expected, apply=False):
  485. """Switch between verified PostgreSQL research generations; monitoring is unchanged."""
  486. need(expected is not None, 'EXPECTED_GENERATION_REQUIRED')
  487. with connect_pg(config, readonly=not apply) as pg:
  488. if apply:
  489. lock_dataset(pg, 'research')
  490. current = descriptor(pg, 'research')
  491. need(current['generation'] == expected, 'UNIFIED_GENERATION_CHANGED')
  492. row = pg.execute('SELECT generation,revision,storage_schema,manifest,pointer FROM '
  493. 'ksf_voc_control.snapshots WHERE dataset=\'research\' AND generation=%s '
  494. 'ORDER BY revision DESC LIMIT 1', (generation,)).fetchone()
  495. need(row is not None, 'UNIFIED_VERSION_NOT_FOUND')
  496. target = dict(zip(('generation', 'revision', 'storage_schema', 'manifest', 'pointer'), row))
  497. c = reconstruct(pg, target)
  498. c.close()
  499. if apply:
  500. pg.execute('UPDATE ksf_voc_control.datasets SET generation=%s,revision=%s,storage_schema=%s,'
  501. 'manifest=%s::jsonb,pointer=%s::jsonb,updated_at=now() WHERE dataset=\'research\'',
  502. (target['generation'], target['revision'], target['storage_schema'],
  503. canonical(target['manifest']), canonical(target['pointer'])))
  504. return {'ok': True, 'readOnly': not apply, 'applied': bool(apply),
  505. 'generation': generation if apply else expected, 'rollbackGeneration': generation,
  506. 'currentGeneration': expected, 'databaseWrites': int(apply),
  507. 'storage': 'parse-postgresql', 'monitoringChanged': False, 'sourceDeleted': False}