Files
synapse/tests/storage/test_federation_store.py
Erik JohnstonandClaude Opus 5.5 50bc702e76 Fix review findings in Rust catch-up and the sticky backlog
- The server filter read each restricted event's state group before it
  checked for partial state, and through a cache that a prefill never
  reached. After an event got its full state, a retry could use the old
  partial group. It now checks first, then reads those groups from the
  database. A prefill of a Python cache now invalidates its Rust mirrors.
- Catch-up ended in two steps, so a PDU that send_pdu skipped in between
  was lost until the next catch-up. One lock now covers both.
- The sticky backlog query reads a fresh snapshot per statement, so
  events could expire between its cleanup and its retry. It now cleans
  up again, a few times, before giving up.
- Tests for each, plus catch-up's batch limit, a failed catch-up send,
  module notification, a filtered-out sticky batch, the hand-off to
  Python, the backlog cleanup boundary and paging outstanding
  destinations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-16 09:27:35 +00:00

766 lines
29 KiB
Python

#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2026 Element Creations Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
"""The Rust federation store (`rust/src/storage/federation_store.rs`) against
the Python store methods it ports, on the same database. These need the native
Rust Postgres driver:
SYNAPSE_TEST_RUST_DRIVER=1 POSTGRES=1 run-trial.sh tests.storage.test_federation_store
"""
from typing import Any
from unittest import SkipTest
from signedjson.key import (
encode_verify_key_base64,
generate_signing_key,
get_verify_key,
)
from twisted.internet.testing import MemoryReactor
from synapse.handlers.e2e_keys import SignatureListItem
from synapse.replication.tcp.streams._base import StickyEventStreamPosition
from synapse.rest import admin
from synapse.rest.client import login, room
from synapse.server import HomeServer
from synapse.storage.rust_pool import RustConnectionPool
from synapse.synapse_rust.federation import FederationStoreForTest
from synapse.types import JsonDict, RoomID
from synapse.util.clock import Clock
from synapse.util.duration import Duration
from tests import unittest
DEST = "remote.example.com"
OTHER = "other.example.com"
ROOM = "!room:test"
def _sorted_edus(edus: list[tuple[str, JsonDict]]) -> list[tuple[str, JsonDict]]:
"""The EDUs in a fixed order. Python orders the users as the database
returns them, and Rust by their first update."""
return sorted(
edus,
key=lambda edu: (edu[0], edu[1]["user_id"], edu[1].get("stream_id", 0)),
)
class FederationStoreTestCase(unittest.HomeserverTestCase):
"""Each test runs a Rust query and the Python store method against the
same fixture and compares."""
def default_config(self) -> dict[str, Any]:
config = super().default_config()
# `get_device_updates_by_remote` only works on a federation sender.
config["federation_sender_instances"] = ["master"]
return config
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
hs = self.setup_test_homeserver()
if not isinstance(
hs.get_datastores().main.db_pool._db_pool, RustConnectionPool
):
raise SkipTest("needs the native Rust Postgres driver")
return hs
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.store = hs.get_datastores().main
db_pool = self.store.db_pool._db_pool
assert isinstance(db_pool, RustConnectionPool)
assert db_pool._pool is not None
self.rust = FederationStoreForTest(db_pool._pool, False, hs.hostname)
self.rust_with_names = FederationStoreForTest(db_pool._pool, True, hs.hostname)
# To-device messages.
def _to_device_edu(self, n: int) -> JsonDict:
return {
"sender": "@alice:test",
"type": "m.test",
"message_id": str(n),
"messages": {"@u:remote": {"D": {"n": n}}},
}
def _queue_to_device(self, destination: str, n: int) -> None:
self.get_success(
self.store.add_remote_messages_from_client_to_device_inbox(
{destination: self._to_device_edu(n)}
)
)
def test_new_device_msgs_for_remote(self) -> None:
for n in range(3):
self._queue_to_device(DEST, n)
self._queue_to_device(OTHER, n)
current = self.store.get_to_device_stream_token()
for last in (0, current - 3, current):
for limit in (0, 1, 2, 10):
self.assertEqual(
self.rust.get_new_device_msgs_for_remote(
DEST, last, current, limit
),
self.get_success(
self.store.get_new_device_msgs_for_remote(
DEST, last, current, limit
)
),
(last, limit),
)
def test_delete_device_msgs_for_remote(self) -> None:
for n in range(3):
self._queue_to_device(DEST, n)
self._queue_to_device(OTHER, 0)
current = self.store.get_to_device_stream_token()
self.rust.delete_device_msgs_for_remote(DEST, current - 2)
messages, _ = self.get_success(
self.store.get_new_device_msgs_for_remote(DEST, 0, current, 10)
)
self.assertEqual(messages, [self._to_device_edu(2)])
messages, _ = self.get_success(
self.store.get_new_device_msgs_for_remote(OTHER, 0, current, 10)
)
self.assertEqual(len(messages), 1)
# Device list updates.
def _poke(self, user_id: str, device_id: str, host: str = DEST) -> None:
self.get_success(
self.store.add_device_change_to_streams(user_id, [device_id], [ROOM])
)
self.get_success(
self.store.add_device_list_outbound_pokes(
user_id=user_id,
device_id=device_id,
room_id=ROOM,
hosts=[host],
context={},
)
)
def _cross_signing_key(self, user_id: str, key_type: str) -> str:
"""Give the user a cross-signing key, and return its device ID."""
public = encode_verify_key_base64(get_verify_key(generate_signing_key("x")))
key = {
"user_id": user_id,
"usage": [key_type],
"keys": {f"ed25519:{public}": public},
}
self.get_success(self.store.set_e2e_cross_signing_key(user_id, key_type, key))
return public
def _device_keys(self, user_id: str, device_id: str) -> None:
self.get_success(
self.store.set_e2e_device_keys(
user_id,
device_id,
self.clock.time_msec(),
{
"user_id": user_id,
"device_id": device_id,
"algorithms": ["m.olm.v1.curve25519-aes-sha2"],
"keys": {f"ed25519:{device_id}": "key"},
"signatures": {user_id: {f"ed25519:{device_id}": "self"}},
},
)
)
def _device_list_fixture(self) -> None:
"""Pokes to DEST for: devices with and without keys and names, a
device's repeated update, a deleted device, a hidden device, a
cross-signed device, and both kinds of cross-signing key update. And
one poke for another destination."""
alice = "@alice:test"
bob = "@bob:test"
self.get_success(self.store.store_device(alice, "A1", "Alice's phone"))
self.get_success(self.store.store_device(alice, "A2", None))
self.get_success(self.store.store_device(bob, "B1", "Bob's laptop"))
self._device_keys(alice, "A1")
self._device_keys(bob, "B1")
master = self._cross_signing_key(alice, "master")
self_signing = self._cross_signing_key(alice, "self_signing")
self.get_success(
self.store.store_e2e_cross_signing_signatures(
alice,
[
SignatureListItem(
signing_key_id=f"ed25519:{self_signing}",
target_user_id=alice,
target_device_id="A1",
signature="cross", # type: ignore[arg-type]
)
],
)
)
self._poke(alice, "A1")
self._poke(bob, "B1")
self._poke(alice, "A2")
self._poke(alice, master)
self._poke(alice, "A1")
self._poke(bob, "GONE")
self._poke(alice, self_signing)
self._poke(bob, "B1", host=OTHER)
# A hidden device is sent as deleted.
self.get_success(self.store.store_device(bob, "HIDDEN", None))
self.get_success(
self.store.db_pool.simple_update_one(
"devices", {"user_id": bob, "device_id": "HIDDEN"}, {"hidden": True}
)
)
self._poke(bob, "HIDDEN")
def _assert_device_updates(self, from_stream_id: int, limit: int) -> None:
now = self.store.get_device_stream_token().stream
python = self.get_success(
self.store.get_device_updates_by_remote(DEST, from_stream_id, limit)
)
for rust, names in ((self.rust, False), (self.rust_with_names, True)):
self.hs.config.federation.allow_device_name_lookup_over_federation = names
python = self.get_success(
self.store.get_device_updates_by_remote(DEST, from_stream_id, limit)
)
stream_id, edus = rust.get_device_updates_by_remote(
DEST, from_stream_id, now, limit
)
self.assertEqual(stream_id, python[0], (from_stream_id, limit, names))
self.assertEqual(
_sorted_edus(edus),
_sorted_edus(python[1]),
(from_stream_id, limit, names),
)
def test_device_updates_by_remote(self) -> None:
self._device_list_fixture()
for limit in (1, 2, 3, 100):
self._assert_device_updates(0, limit)
# The fixture has every kind of update.
_, edus = self.rust_with_names.get_device_updates_by_remote(
DEST, 0, self.store.get_device_stream_token().stream, 100
)
types = [edu_type for edu_type, _ in edus]
self.assertEqual(types.count("m.signing_key_update"), 1)
self.assertEqual(types.count("org.matrix.signing_key_update"), 1)
updates = {
content["device_id"]: content
for edu_type, content in edus
if edu_type == "m.device_list_update"
}
self.assertEqual(set(updates), {"A1", "A2", "B1", "GONE", "HIDDEN"})
self.assertEqual(updates["A1"]["device_display_name"], "Alice's phone")
self.assertIn("cross", str(updates["A1"]["keys"]["signatures"]))
self.assertNotIn("keys", updates["A2"])
self.assertTrue(updates["GONE"]["deleted"])
self.assertTrue(updates["HIDDEN"]["deleted"])
# A1 was updated again after A2, so it follows A2.
self.assertEqual(updates["A1"]["prev_id"], [updates["A2"]["stream_id"]])
def test_device_updates_by_remote_paginates(self) -> None:
"""Walk the stream a few updates at a time, marking each batch sent,
so the prev IDs come from the last batch."""
self._device_list_fixture()
position = 0
for _ in range(10):
self._assert_device_updates(position, 3)
stream_id, edus = self.rust.get_device_updates_by_remote(
DEST, position, self.store.get_device_stream_token().stream, 3
)
if not edus:
break
self.rust.mark_as_sent_devices_by_remote(DEST, stream_id)
position = stream_id
else:
self.fail("never caught up")
def test_mark_as_sent_devices_by_remote(self) -> None:
"""The pokes sent are deleted, and each user's last one recorded."""
self._device_list_fixture()
stream_id, _ = self.get_success(
self.store.get_device_updates_by_remote(DEST, 0, limit=4)
)
pokes = self.get_success(
self.store.db_pool.simple_select_list(
"device_lists_outbound_pokes",
{"destination": DEST},
["user_id", "stream_id"],
)
)
expected: dict[str, int] = {}
for user_id, poke_stream_id in pokes:
if poke_stream_id <= stream_id:
expected[user_id] = max(expected.get(user_id, 0), poke_stream_id)
self.rust.mark_as_sent_devices_by_remote(DEST, stream_id)
remaining = self.get_success(
self.store.db_pool.simple_select_list(
"device_lists_outbound_pokes",
{"destination": DEST},
["stream_id"],
)
)
self.assertTrue(remaining)
self.assertTrue(all(row[0] > stream_id for row in remaining))
last_success = self.get_success(
self.store.db_pool.simple_select_list(
"device_lists_outbound_last_success",
{"destination": DEST},
["user_id", "stream_id"],
)
)
self.assertEqual(dict(last_success), expected)
# Another destination's pokes are left alone.
other = self.get_success(
self.store.db_pool.simple_select_list(
"device_lists_outbound_pokes", {"destination": OTHER}, ["stream_id"]
)
)
self.assertEqual(len(other), 1)
# Marking again moves a user's last success on.
self.rust.mark_as_sent_devices_by_remote(
DEST, self.store.get_device_stream_token().stream
)
last_success = self.get_success(
self.store.db_pool.simple_select_list(
"device_lists_outbound_last_success",
{"destination": DEST},
["user_id", "stream_id"],
)
)
self.assertEqual(
{user_id for user_id, _ in last_success}, {"@alice:test", "@bob:test"}
)
self.assertTrue(
all(stream > expected.get(user_id, 0) for user_id, stream in last_success)
)
def test_set_destination_last_successful_stream_ordering(self) -> None:
self.rust.set_destination_last_successful_stream_ordering(DEST, 5)
self.assertEqual(
self.get_success(
self.store.get_destination_last_successful_stream_ordering(DEST)
),
5,
)
self.rust.set_destination_last_successful_stream_ordering(DEST, 7)
self.assertEqual(
self.get_success(
self.store.get_destination_last_successful_stream_ordering(DEST)
),
7,
)
class CatchUpStoreTestCase(unittest.HomeserverTestCase):
"""The catch-up queries against Python's, on the same database."""
servlets = [
admin.register_servlets,
login.register_servlets,
room.register_servlets,
]
def default_config(self) -> dict[str, Any]:
config = super().default_config()
config["experimental_features"] = {"msc4354_enabled": True}
return config
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
hs = self.setup_test_homeserver()
if not isinstance(
hs.get_datastores().main.db_pool._db_pool, RustConnectionPool
):
raise SkipTest("needs the native Rust Postgres driver")
return hs
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.store = hs.get_datastores().main
db_pool = self.store.db_pool._db_pool
assert isinstance(db_pool, RustConnectionPool)
assert db_pool._pool is not None
self.rust = FederationStoreForTest(db_pool._pool, False, hs.hostname)
self.user = self.register_user("alice", "pass")
self.tok = self.login("alice", "pass")
self.rooms = [
self.helper.create_room_as(self.user, tok=self.tok) for _ in range(3)
]
def _send(self, room_id: str, sticky: bool) -> int:
"""Send a message, record it as due to both destinations, and return
its stream ordering."""
content = {"body": "hi", "msgtype": "m.text"}
if sticky:
event_id = self.helper.send_sticky_event(
room_id,
"m.room.message",
duration=Duration(minutes=10),
content=content,
tok=self.tok,
)["event_id"]
else:
event_id = self.helper.send_event(
room_id, "m.room.message", content, tok=self.tok
)["event_id"]
event = self.get_success(self.store.get_event(event_id))
stream_ordering = event.internal_metadata.stream_ordering
assert stream_ordering is not None
self.get_success(
self.store.store_destination_rooms_entries(
[DEST, OTHER], room_id, stream_ordering
)
)
return stream_ordering
def test_last_successful_stream_ordering(self) -> None:
self.assertIsNone(
self.rust.get_destination_last_successful_stream_ordering(DEST)
)
self.get_success(
self.store.set_destination_last_successful_stream_ordering(DEST, 9)
)
self.assertEqual(
self.rust.get_destination_last_successful_stream_ordering(DEST), 9
)
def test_catch_up_room_event_ids(self) -> None:
orderings = [
self._send(room_id, sticky=False)
for room_id in self.rooms
for _ in range(2)
]
for last in [0, *orderings]:
for destination in (DEST, OTHER, "unknown"):
self.assertEqual(
self.rust.get_catch_up_room_event_ids(destination, last),
self.get_success(
self.store.get_catch_up_room_event_ids(destination, last)
),
)
def test_catch_up_room_event_ids_limit(self) -> None:
"""More rooms are due than one batch takes."""
for i in range(55):
event_id = self.helper.send(self.rooms[0], f"m{i}", tok=self.tok)[
"event_id"
]
event = self.get_success(self.store.get_event(event_id))
stream_ordering = event.internal_metadata.stream_ordering
assert stream_ordering is not None
# A room for each, as the catch-up query takes one event per room.
room_id = f"!r{i}:test"
self.get_success(
self.store.db_pool.simple_insert(
"rooms", {"room_id": room_id, "is_public": False}
)
)
self.get_success(
self.store.store_destination_rooms_entries(
[DEST], room_id, stream_ordering
)
)
python = self.get_success(self.store.get_catch_up_room_event_ids(DEST, 0))
self.assertEqual(len(python), 50)
self.assertEqual(self.rust.get_catch_up_room_event_ids(DEST, 0), python)
def test_prev_events_for_room(self) -> None:
for room_id in self.rooms:
self._send(room_id, sticky=False)
for room_id in [*self.rooms, "!unknown:test"]:
self.assertEqual(
self.rust.get_prev_events_for_room(room_id),
self.get_success(self.store.get_prev_events_for_room(room_id)),
)
def _backlog(self) -> list[tuple[str, str, int]]:
rows = self.get_success(
self.store.db_pool.simple_select_list(
"destination_rooms_sticky_events_backlog",
None,
["destination", "room_id", "sticky_events_stream_position"],
)
)
return sorted(rows)
def _clear_backlog(self) -> None:
self.get_success(
self.store.db_pool.runInteraction(
"clear_backlog",
lambda txn: txn.execute(
"DELETE FROM destination_rooms_sticky_events_backlog"
),
)
)
def test_mark_backlogged_sticky_events_after_catchup_transaction(self) -> None:
orderings = []
for i in range(9):
orderings.append(self._send(self.rooms[i % 3], sticky=i % 2 == 0))
cases = [
(0, orderings[-1] + 1, []),
(0, orderings[-1] + 1, orderings[::3]),
(orderings[2], orderings[7], [orderings[4]]),
(orderings[5], orderings[6], []),
]
backlogged = False
for old, new, sent in cases:
# With no backlog, one before any new position, which stays, and
# one further on, which is moved back.
for existing in (None, 0, 1_000_000):
self._clear_backlog()
if existing is not None:
self.get_success(
self.store.db_pool.simple_insert(
"destination_rooms_sticky_events_backlog",
{
"destination": DEST,
"room_id": self.rooms[0],
"sticky_events_stream_position": existing,
},
)
)
self.get_success(
self.store.mark_backlogged_sticky_events_after_catchup_transaction(
DEST,
old_last_successfully_sent_stream_ordering=old,
new_last_successfully_sent_stream_ordering=new,
event_stream_orderings_sent_in_transaction=sent,
)
)
python = self._backlog()
if existing is None and python:
backlogged = True
self._clear_backlog()
if existing is not None:
self.get_success(
self.store.db_pool.simple_insert(
"destination_rooms_sticky_events_backlog",
{
"destination": DEST,
"room_id": self.rooms[0],
"sticky_events_stream_position": existing,
},
)
)
self.rust.mark_backlogged_sticky_events_after_catchup_transaction(
DEST, old, new, sent
)
self.assertEqual(self._backlog(), python, (old, new, sent, existing))
# The cases did backlog something of their own.
self.assertTrue(backlogged)
def _seed_backlog(self) -> list[int]:
"""Sticky and plain events in each room, with every room backlogged
for `DEST` from its start."""
orderings = [self._send(self.rooms[i % 3], sticky=i % 2 == 0) for i in range(9)]
self.get_success(
self.store.mark_backlogged_sticky_events_after_catchup_transaction(
DEST,
old_last_successfully_sent_stream_ordering=0,
new_last_successfully_sent_stream_ordering=orderings[-1] + 1,
event_stream_orderings_sent_in_transaction=[],
)
)
return orderings
def test_backlogged_sticky_events_for_destination(self) -> None:
self._seed_backlog()
now = self.clock.time_msec()
for limit in (1, 2, 50):
python = self.get_success(
self.store.get_backlogged_sticky_events_for_destination(
DEST, limit=limit
)
)
assert python is not None
room_id, position, event_ids = python
self.assertEqual(
self.rust.get_backlogged_sticky_events_for_destination(
DEST, limit, now
),
(room_id.to_string(), position, event_ids),
)
self.assertIsNone(
self.rust.get_backlogged_sticky_events_for_destination(OTHER, 50, now)
)
def test_backlogged_sticky_events_expire(self) -> None:
"""Once the sticky events expire, the backlog is cleaned up."""
self._seed_backlog()
self.assertNotEqual(self._backlog(), [])
later = self.clock.time_msec() + 60 * 60 * 1000
self.assertIsNone(
self.rust.get_backlogged_sticky_events_for_destination(DEST, 50, later)
)
self.assertEqual(self._backlog(), [])
def test_backlogged_sticky_events_cleanup(self) -> None:
"""A room whose backlog is past its sticky events is cleaned up. One
whose position is at its last sticky event is kept, and sent."""
self._seed_backlog()
rows = self.get_success(
self.store.db_pool.simple_select_list(
"sticky_events", None, ("room_id", "stream_id", "event_id")
)
)
last: dict[str, tuple[int, str]] = {}
for room_id, stream_id, event_id in rows:
if room_id not in last or stream_id > last[room_id][0]:
last[room_id] = (stream_id, event_id)
# The backlog query takes the first room it finds, which is by room
# ID. Leave the last room with something to send, so the others are
# cleaned up first.
*empty, kept = sorted(self.rooms)
seeded = [(DEST, room_id, last[room_id][0] + 1) for room_id in empty]
seeded.append((DEST, kept, last[kept][0]))
now = self.clock.time_msec()
self._clear_backlog()
self._seed_backlog_rows(seeded)
python = self.get_success(
self.store.get_backlogged_sticky_events_for_destination(DEST, limit=50)
)
assert python is not None
python_rows = self._backlog()
self._clear_backlog()
self._seed_backlog_rows(seeded)
rust = self.rust.get_backlogged_sticky_events_for_destination(DEST, 50, now)
self.assertEqual(rust, (kept, last[kept][0], [last[kept][1]]))
self.assertEqual((python[0].to_string(), python[1], python[2]), rust)
self.assertEqual(self._backlog(), python_rows)
self.assertEqual(self._backlog(), [(DEST, kept, last[kept][0])])
def test_mark_backlogged_sticky_events_sent(self) -> None:
self._seed_backlog()
backlog = self.rust.get_backlogged_sticky_events_for_destination(
DEST, 50, self.clock.time_msec()
)
assert backlog is not None
room_id, position, _ = backlog
self.rust.mark_backlogged_sticky_events_sent(DEST, room_id, position)
rust = self._backlog()
self._clear_backlog()
self._seed_backlog_rows(rust)
self.get_success(
self.store.mark_backlogged_sticky_events_sent(
DEST, RoomID.from_string(room_id), StickyEventStreamPosition(position)
)
)
self.assertEqual(self._backlog(), rust)
self.assertIn((DEST, room_id, position + 1), rust)
def _seed_backlog_rows(self, rows: list[tuple[str, str, int]]) -> None:
for destination, room_id, position in rows:
self.get_success(
self.store.db_pool.simple_upsert(
"destination_rooms_sticky_events_backlog",
{"destination": destination, "room_id": room_id},
{"sticky_events_stream_position": position},
)
)
def test_catch_up_outstanding_destinations(self) -> None:
# PDUs for `DEST` and `OTHER`, a to-device message for a third, a
# sticky backlog for a fourth, and a fifth that is backed off.
self._send(self.rooms[0], sticky=False)
self.get_success(
self.store.add_remote_messages_from_client_to_device_inbox(
{
"todevice.example.com": {
"sender": "@alice:test",
"type": "m.test",
"message_id": "1",
"messages": {"@u:todevice.example.com": {"D": {}}},
}
}
)
)
self._seed_backlog_rows([("sticky.example.com", self.rooms[0], 1)])
self.get_success(
self.store.store_destination_rooms_entries(
["backoff.example.com"], self.rooms[0], 1_000_000
)
)
now = self.clock.time_msec()
self.get_success(
self.store.set_destination_retry_timings(
"backoff.example.com", now, now, 60_000
)
)
for after in (None, "", DEST, "zzz"):
for at in (now, now + 120_000):
# Python's reads the clock, so call its transaction directly.
python = self.get_success(
self.store.db_pool.runInteraction(
"outstanding",
self.store._get_catch_up_outstanding_destinations_txn,
at,
after,
)
)
self.assertEqual(
self.rust.get_catch_up_outstanding_destinations(after, at),
python,
(after, at),
)
self.assertIn(
"backoff.example.com",
self.rust.get_catch_up_outstanding_destinations(None, now + 120_000),
)
# More than one page.
self.get_success(
self.store.store_destination_rooms_entries(
[f"d{i:02}.example.com" for i in range(30)], self.rooms[0], 1_000_000
)
)
after = None
pages = []
while True:
python = self.get_success(
self.store.db_pool.runInteraction(
"outstanding",
self.store._get_catch_up_outstanding_destinations_txn,
now,
after,
)
)
self.assertEqual(
self.rust.get_catch_up_outstanding_destinations(after, now), python
)
if not python:
break
pages.append(python)
after = python[-1]
self.assertGreater(len(pages), 1)
self.assertEqual(len(pages[0]), 25)
self.assertNotIn(
"backoff.example.com",
self.rust.get_catch_up_outstanding_destinations(None, now),
)