сообщения больше не выполняется полный scan/rewrite архива для expiry. Очистка остаётся в maintenance и перед MAM-запросами; append и crash checkpoint до fanout сохранены. - Неизменённые native config/persistent index не перезаписываются при каждом сообщении. Удалённые файлы восстанавливаются, ошибки экспорта повторяются. - CSI больше не сохраняет SM snapshot при отсутствии изменения durable очереди. Изменённая очередь сохраняется до отправки; при ошибке записи очередь откатывается. - Личные сообщения, IQ и входящая федерация к аккаунтам больше не захватывают общий MUC mutex, ожидая несвязанную работу с комнатными архивами. - Добавлены регрессии native exports/archive scanning/CSI rollback и реальный TLS latency check в verify-local.sh, автоматически включённый и в CI. Измерения и границы подтверждения: [docs/MESSAGE-LATENCY.md](docs/MESSAGE-LATENCY.md). Результаты итогового прогона: [docs/VALIDATION-0.8.1.md](docs/VALIDATION-0.8.1.md). - Личные сообщения и SM ACK больше не ждут сетевую отправку push. Уведомления отправляются через очередь1024 с максимум8параллельных маршрутов; worker и его задачи отменяются при завершении serve. Ошибки очереди/маршрута логируются. - Offline сообщение сохраняется до публикации push, чтобы проснувшийся клиент мог сразу получить его. Подтверждена очередь при первом TLS stream push gateway. - По умолчанию CSI считает важными bodyless OMEMO legacy/2, delivery receipts и chat markers. Явный список `csi_important_payloads` заменяет расширенные defaults. Низкоприоритетный headline по-прежнему буферизуется. Policy обновляется и при SM resume. - Выбор адресатов и carbons фильтрует владельцев до захвата session mutex; зависшая сессия чужого аккаунта не задерживает этот путь. Duplicate SM ACK без изменения durable counters/queue не переписывает snapshot. - Bind2 и MAM metadata IQ читают только первый/последний native record вместо сканирования всей истории. Индекс не требуется; recovery/лимиты записи сохранены. - Добавлен `message-processing-slow`: обработка message дольше250мс, поля duration_ms/route, без payload/токенов. Это время обработки сервером, не peer receipt. - Новые сетевые регрессии включены в verify-local.sh/GitHub CI. Причины, изменение CSI policy, измерения и ограничения: [docs/PERSONAL-MESSAGE-LATENCY.md](docs/PERSONAL-MESSAGE-LATENCY.md).
256 lines
13 KiB
Python
256 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Measure real STARTTLS direct/MUC delivery with durable SM and CSI.
|
|
|
|
Usage: python3 tests/message_latency_network.py --binary target/release/rust-xmpp
|
|
Reports loopback median/p95/max, not a production latency guarantee. There are
|
|
no millisecond performance assertions: bounded delivery, exact counts, SM
|
|
counters and durable CSI buffering/urgent flush are the regression assertions.
|
|
The source executable is copied before testing so concurrent rebuilds are safe.
|
|
"""
|
|
import argparse
|
|
import asyncio
|
|
import collections
|
|
import datetime
|
|
import hashlib
|
|
import json
|
|
import pathlib
|
|
import shutil
|
|
import socket
|
|
import ssl
|
|
import statistics
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
|
|
from muc_sm_load_network import Client, MUC, start
|
|
from muc_restart_network import stop
|
|
|
|
|
|
class TimedBodies(collections.Counter):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.arrivals = {}
|
|
|
|
def __setitem__(self, key, value):
|
|
self.arrivals.setdefault(key, time.perf_counter())
|
|
super().__setitem__(key, value)
|
|
|
|
|
|
def distribution(values):
|
|
values = sorted(value * 1000 for value in values)
|
|
return {'samples': len(values), 'median_ms': statistics.median(values),
|
|
'p95_ms': values[min(len(values) - 1, int(len(values) * .95))],
|
|
'max_ms': values[-1], 'samples_ms': values}
|
|
|
|
|
|
async def bounded(operation):
|
|
# A functional timeout, deliberately much wider than a performance budget.
|
|
return await asyncio.wait_for(operation, 20)
|
|
|
|
|
|
async def body(client, text):
|
|
await bounded(client.wait(lambda: client.bodies[text] == 1, text))
|
|
return client.bodies.arrivals[text]
|
|
|
|
|
|
def slot(root, client):
|
|
paths = list((root / 'data').rglob(client.token + '.json'))
|
|
assert len(paths) == 1, 'missing or duplicate durable SM record'
|
|
return json.loads(paths[0].read_text())
|
|
|
|
|
|
async def queued(root, client, text):
|
|
# Observe persisted queue state instead of assuming a short socket timeout
|
|
# proves buffering. Bob sends no input/acks that could flush CSI here.
|
|
deadline = time.monotonic() + 20
|
|
while time.monotonic() < deadline:
|
|
record = slot(root, client)
|
|
if any(text in xml for xml in record['csi']['queue']):
|
|
assert client.bodies[text] == 0, 'buffered message leaked onto wire'
|
|
return
|
|
assert client.bodies[text] == 0, 'inactive CSI failed to hold headline'
|
|
await asyncio.sleep(.01)
|
|
raise AssertionError('headline did not enter durable CSI queue')
|
|
|
|
|
|
async def csi_state(root, client, state):
|
|
# Alternate first so persisted state proves this exact control was handled,
|
|
# including resetting any temporary flushing window. A ping on the receiver
|
|
# would itself cause CSI activity and invalidate the buffering experiment.
|
|
for target in (state != 'inactive', state == 'inactive'):
|
|
await client.send(f"<{'inactive' if target else 'active'} xmlns='urn:xmpp:csi:0'/>")
|
|
deadline = time.monotonic() + 20
|
|
while slot(root, client)['csi']['inactive'] != target:
|
|
assert time.monotonic() < deadline, 'CSI state was not durably handled'
|
|
await asyncio.sleep(.001)
|
|
|
|
|
|
async def exercise(root, port, rounds, archive, history_records):
|
|
context = ssl.create_default_context(cafile=str(root / 'cert.pem'))
|
|
clients = []
|
|
report = {'mam_enabled': archive, 'sm_enabled': True, 'rounds': rounds,
|
|
'seeded_muc_archive_records': history_records if archive else 0}
|
|
try:
|
|
for number in range(2):
|
|
client = await bounded(Client.connect(number, port, context, 2))
|
|
clients.append(client)
|
|
client.bodies = TimedBodies()
|
|
# Explicit acks between samples avoid racing inbound ack activity
|
|
# with inactive-CSI assertions. SM remains enabled throughout.
|
|
client.auto_ack = False
|
|
await bounded(client.enable())
|
|
await client.stanza('<presence/>')
|
|
await bounded(client.fence(f'connected-{number}'))
|
|
await client.ack()
|
|
alice, bob = clients
|
|
room = 'latency@rooms.localhost'
|
|
for client in clients:
|
|
await client.stanza(f"<presence to='{room}/Nick-{client.number}'><x xmlns='{MUC}'><history maxstanzas='0'/></x></presence>")
|
|
await bounded(client.wait(lambda c=client: c.presences.get(f'{room}/Nick-{c.number}'), 'MUC self presence'))
|
|
assert client.presences[f'{room}/Nick-{client.number}'].get('type') != 'error'
|
|
for client in clients:
|
|
await bounded(client.fence(f'joined-{client.number}'))
|
|
await client.ack()
|
|
|
|
for state in ('active', 'inactive'):
|
|
measurements = collections.defaultdict(list)
|
|
for turn in range(rounds):
|
|
# Reset CSI immediately before each sample: an important output
|
|
# opens a configured temporary flushing window for later output.
|
|
await csi_state(root, bob, state)
|
|
text = f'{state}-direct-{turn}'
|
|
begun = time.perf_counter()
|
|
await alice.stanza(f"<message to='{bob.jid}' type='chat'><body>{text}</body></message>")
|
|
arrived = await body(bob, text)
|
|
measurements['direct_peer'].append(arrived - begun)
|
|
reply = text + '-reply'
|
|
await bob.stanza(f"<message to='{alice.jid}' type='chat'><body>{reply}</body></message>")
|
|
measurements['direct_application_rtt'].append(await body(alice, reply) - begun)
|
|
await csi_state(root, bob, state)
|
|
text = f'{state}-muc-{turn}'
|
|
begun = time.perf_counter()
|
|
await alice.stanza(f"<message to='{room}' type='groupchat'><body>{text}</body></message>")
|
|
echo, peer = await asyncio.gather(body(alice, text), body(bob, text))
|
|
measurements['muc_sender_echo'].append(echo - begun)
|
|
measurements['muc_peer'].append(peer - begun)
|
|
for client in clients:
|
|
await client.ack()
|
|
report[state] = {name: distribution(values) for name, values in measurements.items()}
|
|
|
|
# A low-priority headline is actually held; a normal chat body must
|
|
# release it in order even while the receiver remains CSI inactive.
|
|
await csi_state(root, bob, 'inactive')
|
|
held, urgent = 'queued-before-urgent', 'urgent-flush-body'
|
|
await alice.stanza(f"<message to='{bob.jid}' type='headline'><body>{held}</body></message>")
|
|
await bounded(alice.fence('headline-routed'))
|
|
await queued(root, bob, held)
|
|
begun = time.perf_counter()
|
|
await alice.stanza(f"<message to='{bob.jid}' type='chat'><body>{urgent}</body></message>")
|
|
held_at, urgent_at = await asyncio.gather(body(bob, held), body(bob, urgent))
|
|
assert held_at <= urgent_at, 'urgent flush reordered queued output'
|
|
report['urgent_flush'] = distribution([urgent_at - begun])
|
|
report['headline_durably_buffered'] = True
|
|
|
|
# Explicit activation must independently drain low-priority output.
|
|
await bob.ack()
|
|
await csi_state(root, bob, 'inactive')
|
|
held = 'queued-before-active'
|
|
await alice.stanza(f"<message to='{bob.jid}' type='headline'><body>{held}</body></message>")
|
|
await bounded(alice.fence('activation-headline-routed'))
|
|
await queued(root, bob, held)
|
|
await bob.send("<active xmlns='urn:xmpp:csi:0'/>")
|
|
await body(bob, held)
|
|
report['activation_drained_queue'] = True
|
|
for client in clients:
|
|
await bounded(client.fence(f'final-{client.number}'))
|
|
await client.ack()
|
|
await bounded(client.handled())
|
|
assert not client.errors, client.errors
|
|
assert all(count == 1 for count in client.bodies.values()), 'duplicate wire delivery'
|
|
archives = list((root / 'data' / 'localhost' / 'archive').glob('*.list'))
|
|
records = sum(sum(line.startswith('item(') for line in path.open()) for path in archives)
|
|
assert (records > 0) == archive, ('MAM toggle did not affect actual archive storage', records)
|
|
report['personal_archive_records'] = records
|
|
muc_archive = root / 'data' / 'rooms%2elocalhost' / 'muc_log' / 'latency.list'
|
|
muc_records = sum(line.startswith('item(') for line in muc_archive.open()) if muc_archive.exists() else 0
|
|
assert muc_records == (history_records + 2 * rounds if archive else 0), ('MUC archive seed/append mismatch', muc_records)
|
|
report['muc_archive_records'] = muc_records
|
|
report['wire_body_deliveries'] = sum(sum(client.bodies.values()) for client in clients)
|
|
assert report['wire_body_deliveries'] == rounds * 8 + 3
|
|
report['status'] = 'pass'
|
|
return report
|
|
finally:
|
|
await asyncio.gather(*(client.close_raw() for client in clients), return_exceptions=True)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--binary', default='target/release/rust-xmpp')
|
|
parser.add_argument('--rounds', type=int, default=20)
|
|
parser.add_argument('--history-records', type=int, nargs='+', default=[0],
|
|
help='MUC native archive sizes to seed before starting each isolated server')
|
|
parser.add_argument('--output', help='optional JSON measurement report')
|
|
args = parser.parse_args()
|
|
assert args.rounds > 0
|
|
assert all(count >= 0 for count in args.history_records)
|
|
with tempfile.TemporaryDirectory(prefix='xmpp-message-latency-') as directory:
|
|
work = pathlib.Path(directory)
|
|
binary = work / 'rust-xmpp'
|
|
shutil.copy2(pathlib.Path(args.binary).resolve(), binary)
|
|
report = {'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(),
|
|
'scope': 'loopback STARTTLS; isolated fixture; no production latency claim', 'cases': []}
|
|
cases = [(False, 0)] + [(True, count) for count in args.history_records]
|
|
for archive, history_records in cases:
|
|
root = work / (f'mam-on-{history_records}' if archive else 'mam-off')
|
|
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',
|
|
'-addext', 'basicConstraints=critical,CA:FALSE', '-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]
|
|
modules = ['tls', 'saslauth', 'smacks', 'csi_simple', 'ping', 'disco']
|
|
if archive:
|
|
modules.append('mam')
|
|
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': modules, 'default_archive_policy': True, 'archive_expires_after': 'never',
|
|
'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}},
|
|
'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256'}},
|
|
'rooms.localhost': {'component': 'muc', 'options': {'muc_room_locking': False,
|
|
'modules_enabled': ['muc_mam'] if archive else [],
|
|
'muc_log_expires_after': 604800}}}}))
|
|
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)
|
|
if archive and history_records:
|
|
# Native Prosody `.list` fixture, matching archive_mam.rs. All
|
|
# timestamps are recent, so retention scans must retain them.
|
|
# Seeding occurs before any server opens this isolated store.
|
|
archive_path = root / 'data' / 'rooms%2elocalhost' / 'muc_log' / 'latency.list'
|
|
archive_path.parent.mkdir(parents=True, exist_ok=True)
|
|
now = time.time()
|
|
stamp = datetime.datetime.fromtimestamp(now, datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
with archive_path.open('w') as output:
|
|
for number in range(history_records):
|
|
output.write("item({name='message';attr={xmlns='jabber:client';type='groupchat';"
|
|
f"from='latency@rooms.localhost/Seed';stamp='{stamp}'" + "};"
|
|
f"key='seed-{number}';when={now};with='message<groupchat';"
|
|
"{name='body';attr={};'seed history'}});\n")
|
|
with (root/'server.log').open('w+') as log:
|
|
server = start(str(binary), config, port, log)
|
|
try:
|
|
report['cases'].append(asyncio.run(exercise(root, port, args.rounds, archive, history_records)))
|
|
finally:
|
|
stop(server)
|
|
result = json.dumps(report, indent=2) + '\n'
|
|
if args.output:
|
|
pathlib.Path(args.output).write_text(result)
|
|
print(result, end='')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|