Files
rust-xmpp/tests/journal_recovery_sync_network.py
T
wt bff609097b
Test, build and publish / All tests and Linux binary (push) Failing after 30s
Test, build and publish / Container smoke test and Gitea publish (push) Skipped
- Уменьшено число последовательных синхронизаций до доставки: ordinary
MAM
  append использует один sync вместо четырёх, SM checkpoint — один
  вместо двух.
  Checksummed redo journals сохраняют commit-before-ACK и
  восстанавливают
  native archive/SM JSON exports после аварии.
- Ограничены рост журналов, ротация и tombstone GC. Старый `.rappend` и
  legacy
  JSON импортируются. Retain/save/purge не возвращают удалённые записи.
- Recovery синхронизирует валидный journal перед публикацией состояния;
  проверка
  checksum не заменяет durability barrier. EIO при adoption блокирует
  startup
  или offline handoff. Новые fault fixtures проверяют сохранность
  журнала и retry.
- Replay SM проверяет checksum/JSON всех frames, но создаёт и валидирует
  полный
  Record только для последнего snapshot, сокращая работу с superseded
  queues.
- `message-processing-slow` содержит времена MAM отправителя/получателя,
  доставки, carbons, offline/push и handled, а также число
  ресурсов/копий.
  Payload и секреты не добавляются в логи.
- Добавлена offline команда `storage-checkpoint CONFIG`: durable экспорт
  и
  передача archive writer/подготовка отката на legacy Rust. Не
  запускайте другого
  writer и не удаляйте journals до успешного завершения команды.
- Новые crash, partial-write, corruption, EIO, recovery, handoff и
  latency
  регрессии включены в GitHub Actions через verify-local.sh; отчёты
  сохраняются.

Измерения, ограничения и эксплуатация:
[docs/DURABLE-MESSAGE-IO.md](docs/DURABLE-MESSAGE-IO.md),
[docs/VALIDATION-0.8.3.md](docs/VALIDATION-0.8.3.md).
2026-10-21 13:14:19 +07:00

200 lines
10 KiB
Python

#!/usr/bin/env python3
"""Fail recovery journal syncs explicitly; never publish a false durable handoff.
Uses independent real TLS fixtures for .wal and .rredo so archive native inode
identity remains genuine. SIGKILL leaves complete valid journals. The scoped
test interposer returns EIO only for the selected suffix beneath fixture data.
"""
import argparse
import asyncio
import hashlib
import json
import os
from pathlib import Path
import shutil
import signal
import socket
import ssl
import subprocess
import tempfile
import time
from muc_restart_network import stop
from sm_restart_network import durable_slot
from slow_storage_latency_network import CARBONS, MeasuredClient, check_shim_scope, iq, sync_rows
from slow_storage_crash_network import committed_archive, start
async def seed(root, binary, config, port, log, env):
context = ssl.create_default_context(cafile=str(root/'cert.pem'))
clients, server = [], None
identifier = 'recovery-sync-body'
try:
server = await asyncio.to_thread(start, binary, config, port, log, env)
for number in range(4):
client = await MeasuredClient.connect(number, port, context, 2)
clients.append(client)
client.auto_ack = False
await client.enable()
await iq(client, f'carbons-{number}', f"<enable xmlns='{CARBONS}'/>")
await client.stanza('<presence/>')
await client.fence(f'ready-{number}')
alice, bob, _, _ = clients
await alice.stanza(f"<message to='{bob.jid}' type='chat' id='{identifier}'><body>{identifier}</body></message>")
await alice.handled()
for client, kind in zip(clients[1:], ('direct', 'sent', 'received')):
await client.wait(lambda c=client,k=kind: c.arrivals[(identifier,k)], 'message/carbon before crash')
server.send_signal(signal.SIGKILL)
assert await asyncio.to_thread(server.wait, 10) == -signal.SIGKILL
records = {client.token: durable_slot(root, client.token) for client in clients}
assert len(records) == 4
for client in clients[1:]:
assert sum(identifier in xml for xml in records[client.token]['pending']) == 1
archives = {p: committed_archive(p) for p in (root/'data'/'localhost'/'archive').glob('*.list')}
assert len(archives) == 2
assert all(sum(identifier in row for row in content.splitlines()) == 1 for content in archives.values())
return records, archives
finally:
if server is not None and server.poll() is None:
await asyncio.to_thread(stop, server)
await asyncio.gather(*(c.close_raw() for c in clients), return_exceptions=True)
def journal_bytes(root, suffix):
return {p: p.read_bytes() for p in (root/'data').rglob('*'+suffix)}
def assert_serve_rejects(binary, config, port, log, env):
server = subprocess.Popen([str(binary), 'serve', str(config), '--experimental'],
env=env, stdout=log, stderr=log)
listened = False
try:
deadline = time.monotonic()+15
while server.poll() is None and time.monotonic() < deadline:
try:
with socket.create_connection(('127.0.0.1', port), timeout=.05):
listened = True
break
except OSError:
time.sleep(.01)
assert not listened, 'SM recovery sync failure exposed a listener'
assert server.wait(timeout=5) != 0, 'SM recovery sync failure reported successful serve'
finally:
if server.poll() is None:
server.kill()
server.wait()
def scope_fault(work, shim):
root = work/'fault-scope'
root.mkdir()
code = """import errno,json,os,pathlib,sys
root,outside,suffix=pathlib.Path(sys.argv[1]),pathlib.Path(sys.argv[2]),sys.argv[3]
statuses=[]
for path in (root/('inside'+suffix),root/'unmatched.txt',outside):
with path.open('wb') as stream:
stream.write(b'fixture');stream.flush()
for sync in (os.fsync,os.fdatasync):
try:sync(stream.fileno());statuses.append(0)
except OSError as error:statuses.append(error.errno)
assert statuses==[errno.EIO,errno.EIO,0,0,0,0],statuses
"""
for suffix in ('.wal', '.rredo'):
log = work/('scope-'+suffix[1:]+'.jsonl')
env = dict(os.environ, LD_PRELOAD=str(shim), XMPP_SLOW_SYNC_ROOT=str(root),
XMPP_SLOW_SYNC_LOG=str(log), XMPP_SLOW_SYNC_MS='0', XMPP_SYNC_FAIL_SUFFIX=suffix)
subprocess.run(['python3', '-c', code, str(root), str(work/('outside'+suffix)), suffix],
env=env, check=True, capture_output=True)
rows = sync_rows(log)
assert len(rows) == 4 and sum(row['injected'] for row in rows) == 2
def run_case(work, binary, shim, suffix):
root = work/suffix[1:]
root.mkdir()
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256',
'-nodes', '-days', '1', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost',
'-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem')], check=True, capture_output=True)
with socket.socket() as probe:
probe.bind(('127.0.0.1', 0))
port = probe.getsockname()[1]
config = root/'config.json'
config.write_text(json.dumps({'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'],
'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [],
'modules_enabled': ['tls', 'saslauth', 'smacks', 'ping', 'mam', 'carbons'],
'smacks_hibernation_time': 600, 'archive_expires_after': 'never', 'default_archive_policy': True,
'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}},
'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256'}}}}))
for number in range(2):
subprocess.run([str(binary), 'create-user', str(config), f'load-{number}', '--password-stdin'],
input='load-test-password\n', text=True, check=True, capture_output=True)
sync_log = root/'sync.jsonl'
env = dict(os.environ, LD_PRELOAD=str(shim), XMPP_SLOW_SYNC_ROOT=str(root/'data'),
XMPP_SLOW_SYNC_LOG=str(sync_log), XMPP_SLOW_SYNC_MS='0')
env.pop('XMPP_SYNC_FAIL_SUFFIX', None)
with (root/'server.log').open('w+') as log:
records, archives = asyncio.run(seed(root, binary, config, port, log, env))
journals = journal_bytes(root, suffix)
assert journals, 'network fixture did not leave requested journals'
fault_env = dict(env, XMPP_SYNC_FAIL_SUFFIX=suffix)
before = len(sync_rows(sync_log))
command = [str(binary), 'storage-checkpoint', str(config)]
result = subprocess.run(command, env=fault_env, capture_output=True, text=True)
assert result.returncode != 0, f'{suffix} recovery skipped journal sync and falsely reported successful handoff'
assert 'Durable native archive handoff:' not in result.stdout, 'failed checkpoint advertised handoff success'
attempts = [row for row in sync_rows(sync_log)[before:] if row.get('injected')]
assert attempts and all(row['result'] == -1 for row in attempts), 'recovery did not attempt faulted journal sync'
assert journal_bytes(root, suffix) == journals, 'failed checkpoint removed or changed authoritative journal'
if suffix == '.wal':
before_serve = len(sync_rows(sync_log))
assert_serve_rejects(binary, config, port, log, fault_env)
assert any(row.get('injected') and row['store'] == 'sm'
for row in sync_rows(sync_log)[before_serve:]), 'serve failed without attempting SM recovery sync'
assert journal_bytes(root, suffix) == journals, 'failed serve changed authoritative SM journals'
subprocess.run(command, env=env, check=True, capture_output=True, text=True)
assert not journal_bytes(root, '.wal') and not journal_bytes(root, '.rredo'), 'successful handoff left journals'
for token, record in records.items():
paths = list((root/'data').rglob(token+'.json'))
assert len(paths) == 1
exported = json.loads(paths[0].read_text())
assert {k:v for k,v in exported.items() if k != 'deadline_ms'} == {
k:v for k,v in record.items() if k != 'deadline_ms'}, 'handoff changed acknowledged SM state'
assert all(path.read_text() == content for path, content in archives.items()), 'handoff lost acknowledged MAM body'
return {'suffix': suffix, 'status': 'pass', 'checkpoint_rejected_sync_failure': True,
'journals_preserved_on_failure': len(journals),
'serve_rejected_before_listeners': suffix == '.wal',
'clean_retry_preserved_sessions': len(records), 'clean_retry_preserved_archives': len(archives)}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--binary', default='target/release/rust-xmpp')
parser.add_argument('--output', default='/tmp/journal-recovery-sync.json')
parser.add_argument('--suffix', nargs='+', choices=['.wal', '.rredo'], default=['.wal', '.rredo'])
args = parser.parse_args()
report = {'status': 'running', 'cases': []}
try:
with tempfile.TemporaryDirectory(prefix='xmpp-journal-recovery-sync-') as directory:
work = Path(directory)
binary = work/'rust-xmpp'
shutil.copy2(Path(args.binary).resolve(), binary)
report['binary_sha256'] = hashlib.sha256(binary.read_bytes()).hexdigest()
shim = work/'slow_sync.so'
subprocess.run(['cc', '-shared', '-fPIC', '-O2', '-Wall', '-Wextra', '-Werror',
str(Path(__file__).parent/'fixtures'/'slow_sync.c'), '-o', str(shim), '-ldl'], check=True)
check_shim_scope(work, shim)
scope_fault(work, shim)
for suffix in args.suffix:
report['cases'].append(run_case(work, binary, shim, suffix))
report['status'] = 'pass'
except BaseException as error:
report.update(status='fail', failure=str(error))
raise
finally:
Path(args.output).write_text(json.dumps(report, indent=2)+'\n')
print(json.dumps(report, indent=2))
if __name__ == '__main__':
main()