Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/19966.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix server key cache invalidations being silently dropped on workers.
22 changes: 22 additions & 0 deletions synapse/storage/databases/main/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@
"_get_e2e_cross_signing_signatures_for_device"
)

# As above: this cache takes a single argument which is itself a tuple, which
# requires special handling.
GET_SERVER_KEYS_JSON_CACHE_NAME = "_get_server_keys_json"

# How long between cache invalidation table cleanups, once we have caught up
# with the backlog.
REGULAR_CLEANUP_INTERVAL = Duration(hours=1)
Expand Down Expand Up @@ -305,6 +309,24 @@ def process_replication_rows(
self._get_e2e_cross_signing_signatures_for_device.invalidate( # type: ignore[attr-defined]
((user_id, device_id),)
)
elif row.cache_func == GET_SERVER_KEYS_JSON_CACHE_NAME:
# As above: each entry in "keys" is a JSON-encoded
# (server_name, key_id) pair, since the cache takes a single
# argument which is itself a tuple and we cannot send nested
# information over replication.
for json_str in row.keys:
try:
server_name, key_id = json.loads(json_str)
except (json.JSONDecodeError, TypeError, ValueError):
logger.error(
"Failed to deserialise cache key as valid JSON: %s",
json_str,
)
continue

self._get_server_keys_json.invalidate( # type: ignore[attr-defined]
((server_name, key_id),)
)
else:
self._attempt_to_invalidate_cache(row.cache_func, row.keys)

Expand Down
16 changes: 13 additions & 3 deletions synapse/storage/databases/main/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,20 @@ def store_server_keys_response_txn(txn: LoggingTransaction) -> None:
# invalidate takes a tuple corresponding to the params of
# _get_server_keys_json. _get_server_keys_json only takes one
# param, which is itself the 2-tuple (server_name, key_id).
self._invalidate_cache_and_stream_bulk(
#
# Invalidate the local cache directly, but we can only send
# primitive types per argument over replication, so JSON-encode the
# nested key and unpack it on the receiving side (see
# `CacheInvalidationWorkerStore.process_replication_rows`).
for key_id in verify_keys:
txn.call_after(
self._get_server_keys_json.invalidate,
((server_name, key_id),),
)
self._send_invalidation_to_replication_bulk(
txn,
self._get_server_keys_json,
[((server_name, key_id),) for key_id in verify_keys],
self._get_server_keys_json.__name__,
[(json.dumps([server_name, key_id]),) for key_id in verify_keys],
)
self._invalidate_cache_and_stream_bulk(
txn,
Expand Down
51 changes: 51 additions & 0 deletions tests/storage/databases/main/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@
#
from unittest.mock import Mock, call

from signedjson.key import generate_signing_key, get_verify_key

from synapse.storage.database import LoggingTransaction
from synapse.storage.keys import FetchKeyResult

from tests.replication._base import BaseMultiWorkerStreamTestCase
from tests.unittest import HomeserverTestCase
Expand Down Expand Up @@ -122,3 +125,51 @@ def test_txn(txn: LoggingTransaction) -> None:
[call(key_list) for key_list in keys_to_invalidate],
any_order=True,
)

def test_server_keys_json_invalidation_replicates(self) -> None:
"""`_get_server_keys_json` takes a single argument which is itself a
tuple, and we can't send nested keys over replication: the keys are
JSON-encoded on the sending side and decoded again in
`process_replication_rows`. Check the round trip invalidates the right
cache entry on the worker.
"""
master_invalidate = Mock()
worker_invalidate = Mock()

self.store._get_server_keys_json.invalidate = master_invalidate
worker = self.make_worker_hs("synapse.app.generic_worker")
worker_ds = worker.get_datastores().main
worker_ds._get_server_keys_json.invalidate = worker_invalidate

signing_key = generate_signing_key("ver1")
verify_key = get_verify_key(signing_key)
key_id = f"{verify_key.alg}:{verify_key.version}"

assert self.store._cache_id_gen is not None
initial_token = self.store._cache_id_gen.get_current_token()

self.get_success(
self.store.store_server_keys_response(
"server1",
from_server="server2",
ts_added_ms=self.clock.time_msec(),
verify_keys={
key_id: FetchKeyResult(
verify_key=verify_key, valid_until_ts=200_000
),
},
response_json={},
)
)
second_token = self.store._cache_id_gen.get_current_token()
self.assertGreater(second_token, initial_token)

self.get_success(
worker.get_replication_data_handler().wait_for_stream_position(
"master", "caches", second_token
)
)

expected_key = (("server1", key_id),)
master_invalidate.assert_called_once_with(expected_key)
worker_invalidate.assert_called_once_with(expected_key)
Loading