сообщения больше не выполняется полный 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).
271 lines
14 KiB
Python
271 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Separate personal-chat wire delivery, handled SM ACK and client receipt timing.
|
|
|
|
Native personal MAM/roster fixtures, real loopback STARTTLS, durable SM/CSI.
|
|
A forced CSI dwell is reported explicitly and is not a server performance sample.
|
|
The default exercises both the runtime defaults (urgent encrypted/receipt payloads)
|
|
and an explicit empty importance list (native policy). Use --expect-held for the
|
|
old binary; --csi-policy default limits a baseline run to its default policy.
|
|
"""
|
|
import argparse
|
|
import asyncio
|
|
import collections
|
|
import hashlib
|
|
import json
|
|
import pathlib
|
|
import shutil
|
|
import socket
|
|
import ssl
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import xml.etree.ElementTree as ET
|
|
|
|
from message_latency_network import bounded, csi_state, distribution, slot
|
|
from muc_sm_load_network import Client, CLIENT, SM, start
|
|
from muc_restart_network import stop
|
|
|
|
|
|
class ObservedReader:
|
|
"""Observe complete wire stanzas while retaining the existing client's parser."""
|
|
def __init__(self, reader, client):
|
|
self.reader, self.client = reader, client
|
|
self.parser = ET.XMLPullParser(events=('start', 'end'))
|
|
self.parser.feed("<root xmlns='jabber:client'>")
|
|
self.root = next(self.parser.read_events())[1]
|
|
self.depth = 0
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self.reader, name)
|
|
|
|
async def read(self, size):
|
|
chunk = await self.reader.read(size)
|
|
self.parser.feed(chunk)
|
|
for event, node in self.parser.read_events():
|
|
self.depth += 1 if event == 'start' else -1
|
|
if event == 'end' and self.depth == 0:
|
|
now = time.perf_counter()
|
|
if node.tag == f'{{{CLIENT}}}message' and node.get('id'):
|
|
self.client.message_times[node.get('id')].append(now)
|
|
if node.tag == f'{{{SM}}}a':
|
|
self.client.ack_times[int(node.get('h'))] = now
|
|
self.root.remove(node)
|
|
return chunk
|
|
|
|
|
|
class MeasuredClient(Client):
|
|
def __init__(self, *args):
|
|
super().__init__(*args)
|
|
self.message_times = collections.defaultdict(list)
|
|
self.ack_times = {}
|
|
self.reader = ObservedReader(self.reader, self)
|
|
|
|
|
|
async def seen(client, identifier):
|
|
await bounded(client.wait(lambda: client.message_times[identifier], identifier))
|
|
assert len(client.message_times[identifier]) == 1
|
|
return client.message_times[identifier][0]
|
|
|
|
|
|
async def send_measured(sender, receiver, identifier, payload, typ='chat'):
|
|
begun = time.perf_counter()
|
|
await sender.stanza(f"<message to='{receiver.jid}' type='{typ}' id='{identifier}'>{payload}</message>")
|
|
await bounded(sender.handled())
|
|
ack_at = sender.ack_times[sender.inputs]
|
|
return begun, ack_at
|
|
|
|
|
|
async def exercise(root, port, args, policy):
|
|
clients = []
|
|
try:
|
|
context = ssl.create_default_context(cafile=str(root/'cert.pem'))
|
|
for n in range(2):
|
|
client = await bounded(MeasuredClient.connect(n, port, context, 2))
|
|
clients.append(client)
|
|
client.auto_ack = False
|
|
await bounded(client.enable())
|
|
await client.stanza('<presence/>')
|
|
await bounded(client.fence(f'connected-{n}'))
|
|
await client.ack()
|
|
alice, bob = clients
|
|
results = {}
|
|
for state in ('active', 'inactive'):
|
|
values = collections.defaultdict(list)
|
|
for turn in range(args.rounds):
|
|
await csi_state(root, alice, 'active')
|
|
await csi_state(root, bob, state)
|
|
identifier = f'{state}-body-{turn}'
|
|
begun, ack_at = await send_measured(alice, bob, identifier, '<body>personal test</body>')
|
|
delivery = await seen(bob, identifier)
|
|
receipt_id = identifier + '-receipt'
|
|
# Receipt generation belongs to the receiving client; the server
|
|
# does not synthesize XEP-0184 receipts on SM acknowledgement.
|
|
receipt_sent = time.perf_counter()
|
|
await bob.stanza(f"<message to='{alice.jid}' type='chat' id='{receipt_id}'><received xmlns='urn:xmpp:receipts' id='{identifier}'/></message>")
|
|
receipt = await seen(alice, receipt_id)
|
|
values['delivery'].append(delivery-begun)
|
|
values['sender_sm_handled'].append(ack_at-begun)
|
|
values['client_receipt_end_to_end'].append(receipt-begun)
|
|
values['receipt_return_leg'].append(receipt-receipt_sent)
|
|
for client in clients:
|
|
await client.ack()
|
|
results[state] = {name: distribution(v) for name, v in values.items()}
|
|
|
|
payloads = {
|
|
'receipt_only': "<received xmlns='urn:xmpp:receipts' id='earlier-message'/>",
|
|
'omemo_legacy_only': "<encrypted xmlns='eu.siacs.conversations.axolotl'><header sid='1'><iv>AA==</iv></header><payload>AA==</payload></encrypted>",
|
|
'omemo_2_only': "<encrypted xmlns='urn:xmpp:omemo:2'><header sid='1'><iv>AA==</iv></header><payload>AA==</payload></encrypted>",
|
|
'omemo_with_eme': "<encrypted xmlns='eu.siacs.conversations.axolotl'><payload>AA==</payload></encrypted><encryption xmlns='urn:xmpp:eme:0' namespace='eu.siacs.conversations.axolotl'/>",
|
|
}
|
|
payloads.update({f'marker_{name}':
|
|
f"<{name} xmlns='urn:xmpp:chat-markers:0' id='earlier-message'/>"
|
|
for name in ('received', 'displayed', 'acknowledged')})
|
|
payloads['low_priority_headline'] = '<body>held low priority</body>'
|
|
for name, payload in payloads.items():
|
|
for state in ('active', 'inactive'):
|
|
await csi_state(root, alice, 'active')
|
|
await csi_state(root, bob, state)
|
|
identifier = name + '-' + state
|
|
begun, ack_at = await send_measured(alice, bob, identifier, payload,
|
|
'headline' if name == 'low_priority_headline' else 'chat')
|
|
deadline = time.monotonic() + 20
|
|
while True:
|
|
buffered = any(identifier in xml for xml in slot(root, bob)['csi']['queue'])
|
|
if buffered or bob.message_times[identifier]:
|
|
break
|
|
assert time.monotonic() < deadline, identifier
|
|
await asyncio.sleep(.001)
|
|
expect_held = state == 'inactive' and (
|
|
name == 'low_priority_headline' or (
|
|
name != 'omemo_with_eme' and (policy == 'native' or args.expect_held)))
|
|
assert buffered == expect_held, (policy, identifier, buffered, expect_held)
|
|
activation = None
|
|
if buffered:
|
|
assert not bob.message_times[identifier]
|
|
await asyncio.sleep(args.csi_dwell)
|
|
assert not bob.message_times[identifier], 'CSI unexpectedly emitted held payload'
|
|
activation = time.perf_counter()
|
|
await bob.send("<active xmlns='urn:xmpp:csi:0'/>")
|
|
delivered = await seen(bob, identifier)
|
|
results[identifier] = {'delivery_ms': (delivered-begun)*1000,
|
|
'sender_sm_handled_ms': (ack_at-begun)*1000,
|
|
'durably_csi_buffered': buffered,
|
|
'forced_dwell_seconds': args.csi_dwell if buffered else 0,
|
|
'after_activation_ms': (delivered-activation)*1000 if activation else None}
|
|
for client in clients:
|
|
await client.ack()
|
|
|
|
await csi_state(root, bob, 'active')
|
|
# Keep receiving/draining but deliberately withhold recipient ACKs.
|
|
for n in range(args.backlog):
|
|
await alice.stanza(f"<message to='{bob.jid}' type='chat' id='backlog-{n}'><body>unacked backlog</body></message>")
|
|
await seen(bob, f'backlog-{n}')
|
|
if args.backlog:
|
|
await seen(bob, f'backlog-{args.backlog-1}')
|
|
await bounded(alice.handled())
|
|
pending = len(slot(root, bob)['pending'])
|
|
begun, ack_at = await send_measured(alice, bob, 'after-backlog', '<body>after backlog</body>')
|
|
delivery = await seen(bob, 'after-backlog')
|
|
results['sm_backlog'] = {'seeded_messages': args.backlog, 'durable_pending_before_sample': pending,
|
|
'delivery_ms': (delivery-begun)*1000, 'sender_sm_handled_ms': (ack_at-begun)*1000}
|
|
for client in clients:
|
|
await client.ack()
|
|
assert not client.errors, client.errors
|
|
results['status'] = 'pass'
|
|
return results
|
|
finally:
|
|
await asyncio.gather(*(c.close_raw() for c 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=3)
|
|
parser.add_argument('--history-records', type=int, default=100000)
|
|
parser.add_argument('--roster-sizes', type=int, nargs='+', default=[0, 5000, 10000])
|
|
parser.add_argument('--backlog', type=int, default=500)
|
|
parser.add_argument('--csi-dwell', type=float, default=2)
|
|
parser.add_argument('--csi-policy', choices=('default', 'native', 'both'), default='both',
|
|
help='native explicitly configures csi_important_payloads=[]; both verifies override')
|
|
parser.add_argument('--expect-held', action='store_true',
|
|
help='baseline binaries: expect native holds even when option is absent')
|
|
parser.add_argument('--output')
|
|
args = parser.parse_args()
|
|
assert args.rounds > 0 and args.history_records >= 0
|
|
assert all(count >= 0 for count in args.roster_sizes)
|
|
assert 0 <= args.backlog < 4000 and args.csi_dwell >= 0
|
|
with tempfile.TemporaryDirectory(prefix='xmpp-personal-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; fixture only; forced CSI dwell explicitly separated', 'cases': []}
|
|
policies = ('default', 'native') if args.csi_policy == 'both' else (args.csi_policy,)
|
|
for roster_size, policy in ((n, p) for n in args.roster_sizes for p in policies):
|
|
root = work/f'roster-{roster_size}-{policy}'
|
|
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', 'csi_simple', 'ping', 'mam', 'roster'],
|
|
'default_archive_policy': 'roster' if roster_size else 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'}}}}))
|
|
if policy == 'native':
|
|
configured = json.loads(config.read_text())
|
|
configured['global']['csi_important_payloads'] = []
|
|
config.write_text(json.dumps(configured))
|
|
for n in range(2):
|
|
subprocess.run([str(binary), 'create-user', str(config), f'load-{n}', '--password-stdin'],
|
|
input='load-test-password\n', text=True, check=True, capture_output=True)
|
|
archive = root/'data'/'localhost'/'archive'/f'load%2d{n}.list'
|
|
archive.parent.mkdir(parents=True, exist_ok=True)
|
|
now = time.time()
|
|
with archive.open('w') as output:
|
|
for i in range(args.history_records):
|
|
output.write("item({name='message';attr={xmlns='jabber:client';type='chat'};"
|
|
f"key='seed-{i}';when={now};with='load-{1-n}@localhost';"
|
|
"{name='body';attr={};'native personal history'}});\n")
|
|
if roster_size:
|
|
roster = root/'data'/'localhost'/'roster'/f'load%2d{n}.dat'
|
|
roster.parent.mkdir(parents=True, exist_ok=True)
|
|
with roster.open('w') as output:
|
|
output.write("return { [false]={version=1};\n")
|
|
output.write(f"['load-{1-n}@localhost']={{subscription='none'}};\n")
|
|
for i in range(roster_size-1):
|
|
output.write(f"['contact-{i}@example.invalid']={{subscription='none'}};\n")
|
|
output.write('}\n')
|
|
with (root/'server.log').open('w+') as log:
|
|
server = start(str(binary), config, port, log)
|
|
try:
|
|
results = asyncio.run(exercise(root, port, args, policy))
|
|
counts = [sum(line.startswith('item(') for line in path.open())
|
|
for path in sorted((root/'data'/'localhost'/'archive').glob('*.list'))]
|
|
expected = args.history_records + 4*args.rounds + 10 + args.backlog + 1
|
|
assert counts == [expected, expected], (counts, expected)
|
|
results['final_personal_mam_records_per_user'] = counts
|
|
results.update({'csi_policy': policy, 'baseline_expect_held': args.expect_held,
|
|
'roster_contacts_per_user': roster_size,
|
|
'seeded_personal_mam_records_per_user': args.history_records,
|
|
'archive_policy': 'roster' if roster_size else 'always'})
|
|
report['cases'].append(results)
|
|
if args.output:
|
|
pathlib.Path(args.output).write_text(json.dumps(report, indent=2)+'\n')
|
|
print(f'personal latency: roster={roster_size} csi={policy} pass', flush=True)
|
|
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()
|