| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169 |
- """Reconciliation acceptance against a disposable loopback PostgreSQL instance."""
- from contextlib import closing
- import importlib.util
- import json
- import os
- from pathlib import Path
- import sqlite3
- import subprocess
- import sys
- import tempfile
- from types import SimpleNamespace
- import unittest
- ROOT = Path(__file__).resolve().parents[1]
- SCRIPT = ROOT / 'scripts/reconcile-unified-monitoring.py'
- RUNTIME = ROOT / 'docs/unified-database-migration-20261109/release-r3/package'
- spec = importlib.util.spec_from_file_location('reconcile', SCRIPT)
- repair = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(repair)
- store = repair.load_store(RUNTIME)
- CONFIG = os.environ.get('KSF_TEST_UNIFIED_CONFIG')
- GENERATION = 'a' * 64
- WORKER = 'b' * 32
- @unittest.skipUnless(CONFIG, 'requires disposable local PostgreSQL')
- class ReconciliationTests(unittest.TestCase):
- def setUp(self):
- config = store.configuration(CONFIG)
- self.assertEqual(config['connection'].get('host'), '127.0.0.1')
- self.assertEqual(int(config['connection'].get('port', 0)), 55439)
- self.assertEqual(os.environ.get('KSF_UNIFIED_TEST_DISPOSABLE'), '1')
- with store.connect_pg(CONFIG) as pg:
- for name, in pg.execute("SELECT nspname FROM pg_namespace WHERE nspname LIKE 'ksfv_%' OR nspname='ksf_voc_control'").fetchall():
- pg.execute('DROP SCHEMA ' + store.identifier(name) + ' CASCADE')
- self.temp = tempfile.TemporaryDirectory()
- self.addCleanup(self.temp.cleanup)
- self.root = Path(self.temp.name)
- (self.root / 'source').mkdir()
- self.source = self.root / 'source/monitoring.sqlite'
- research = self.root / 'research.sqlite'
- with closing(sqlite3.connect(research)) as c:
- c.executescript('CREATE TABLE meta(key TEXT PRIMARY KEY,value TEXT);'
- 'CREATE TABLE products(id TEXT PRIMARY KEY,title TEXT);')
- c.executemany('INSERT INTO meta VALUES(?,?)', [('generation', GENERATION),
- ('schema', 'ksf.voc-library.v2'), ('status', 'ready')])
- c.execute('INSERT INTO products VALUES(?,?)', ('p1', 'unchanged'))
- c.commit()
- with closing(sqlite3.connect(self.source)) as c:
- c.executescript('CREATE TABLE meta(key TEXT PRIMARY KEY,value TEXT);'
- 'CREATE TABLE pipeline_workers(id TEXT PRIMARY KEY,generation TEXT NOT NULL,'
- 'heartbeat TEXT NOT NULL,interval_seconds INTEGER NOT NULL,state TEXT NOT NULL);'
- 'CREATE TABLE events(id INTEGER PRIMARY KEY,body TEXT NOT NULL);')
- c.execute('INSERT INTO events VALUES(1,?)', ('original-event',))
- c.execute('INSERT INTO pipeline_workers VALUES(?,?,?,?,?)',
- (WORKER, GENERATION, '2026-11-01T13:24:55+00:00', 10, 'WATCHING'))
- c.commit()
- store.migrate(research, CONFIG, apply=True)
- store.migrate(self.source, CONFIG, 'monitoring', apply=True)
- self.modify('UPDATE pipeline_workers SET heartbeat=? WHERE id=?',
- ('2026-11-01T13:46:57+00:00', WORKER))
- self.counter = 0
- def modify(self, sql, args=()):
- with closing(sqlite3.connect(self.source)) as c:
- c.execute(sql, args)
- c.commit()
- def run_step(self, command, plan=None):
- self.counter += 1
- return repair.run(SimpleNamespace(command=command, runtime_root=str(RUNTIME),
- source=str(self.source), config=CONFIG, expected_research_generation=GENERATION,
- out=str(self.root / ('result-' + str(self.counter))), plan=str(plan) if plan else None))
- def plan(self):
- value = self.run_step('plan')
- return self.root / ('result-' + str(self.counter)) / 'plan.json', value
- def descriptor(self):
- with store.connect_pg(CONFIG, readonly=True) as pg:
- return repair.brief(store.descriptor(pg, 'monitoring'))
- def test_original_r3_reapply_rejects_changed_source(self):
- with self.assertRaisesRegex(ValueError, 'EXISTING_DATASET_REQUIRES_EXPECTED_GENERATION'):
- store.migrate(self.source, CONFIG, 'monitoring', apply=True)
- def test_plan_apply_full_verify_retry_and_prior_snapshot_retained(self):
- before = self.descriptor()
- path, plan = self.plan()
- self.assertEqual(before, self.descriptor())
- self.assertEqual(len(plan['workerChanges']), 1)
- result = self.run_step('apply', path)
- self.assertEqual(result['operation'], 'new-sealed-monitoring-snapshot')
- self.assertTrue(result['allRowsVerified'])
- self.assertEqual(result['businessRowsChanged'], 0)
- after = self.descriptor()
- self.assertNotEqual(before['generation'], after['generation'])
- self.assertEqual(after['revision'], 1)
- self.assertEqual(self.run_step('apply', path)['operation'], 'already-converged')
- self.assertEqual(self.run_step('verify', path)['operation'], 'already-converged')
- with store.connect_pg(CONFIG, readonly=True) as pg:
- self.assertIsNotNone(pg.execute('SELECT 1 FROM pg_namespace WHERE nspname=%s',
- (before['storage_schema'],)).fetchone())
- old = pg.execute('SELECT count(*) FROM ksf_voc_control.snapshots WHERE dataset=\'monitoring\'').fetchone()[0]
- self.assertEqual(old, 2)
- def test_non_worker_change_rejected_without_target_writes(self):
- before = self.descriptor()
- self.modify('UPDATE events SET body=? WHERE id=1', ('must-not-import',))
- with self.assertRaisesRegex(ValueError, 'NON_WORKER_DATA_CHANGED'):
- self.plan()
- self.assertEqual(before, self.descriptor())
- def test_changed_source_after_plan_rejected(self):
- path, _ = self.plan()
- before = self.descriptor()
- self.modify('UPDATE pipeline_workers SET heartbeat=?', ('2026-11-01T14:00:00+00:00',))
- with self.assertRaisesRegex(ValueError, 'PLAN_OR_SOURCE_CHANGED'):
- self.run_step('apply', path)
- self.assertEqual(before, self.descriptor())
- def test_pg_revision_changed_after_plan_rejected(self):
- path, _ = self.plan()
- with closing(store.MonitoringConnection(CONFIG)) as c:
- c.execute('UPDATE events SET body=? WHERE id=1', ('new-pg-event',))
- c.commit()
- before = self.descriptor()
- with self.assertRaisesRegex(ValueError, 'NON_WORKER_DATA_CHANGED'):
- self.run_step('apply', path)
- self.assertEqual(before, self.descriptor())
- def test_new_worker_and_stop_status_preserve_all_other_tables(self):
- self.modify('INSERT INTO pipeline_workers VALUES(?,?,?,?,?)',
- ('c' * 32, GENERATION, '2026-11-01T13:46:58+00:00', 10, 'STOPPED'))
- self.modify('UPDATE pipeline_workers SET state=? WHERE id=?', ('STOPPED', WORKER))
- path, plan = self.plan()
- self.assertEqual(len(plan['workerChanges']), 2)
- self.assertTrue(self.run_step('apply', path)['allRowsVerified'])
- def test_history_removal_and_backward_heartbeat_rejected(self):
- self.modify('UPDATE pipeline_workers SET heartbeat=?', ('2026-11-01T13:00:00+00:00',))
- with self.assertRaisesRegex(ValueError, 'WORKER_HEARTBEAT_MOVED_BACKWARDS'):
- self.plan()
- self.modify('DELETE FROM pipeline_workers')
- with self.assertRaisesRegex(ValueError, 'WORKER_HISTORY_REMOVAL_REJECTED'):
- self.plan()
- def test_schema_change_rejected(self):
- self.modify('ALTER TABLE events ADD COLUMN extra TEXT')
- with self.assertRaisesRegex(ValueError, 'SOURCE_SCHEMA_CHANGED'):
- self.plan()
- def test_verify_before_apply_rejected(self):
- path, _ = self.plan()
- with self.assertRaisesRegex(ValueError, 'TARGET_NOT_CONVERGED'):
- self.run_step('verify', path)
- def test_cli_invocation_uses_sealed_module(self):
- result = subprocess.run([sys.executable, '-B', str(SCRIPT), 'plan',
- '--runtime-root', str(RUNTIME), '--source', str(self.source), '--config', CONFIG,
- '--expected-research-generation', GENERATION, '--out', str(self.root / 'cli-plan')],
- capture_output=True, timeout=30)
- self.assertEqual(result.returncode, 0, result.stdout.decode())
- self.assertTrue(json.loads(result.stdout)['ok'])
- self.assertEqual(json.loads((self.root / 'cli-plan/plan.json').read_text('utf-8'))['storeSha256'], repair.R3_STORE_SHA)
- if __name__ == '__main__':
- unittest.main()
|