Fix storage type mismatches where values didn't match their column types (#19911)

A handful of places in the storage layer bound a value whose Python type
didn't match the declared column type — an `int` into a `TEXT` column,
or a `str` into a `BIGINT` column — and relied on psycopg2's loose
coercion to paper over the mismatch. These are latent correctness bugs:
they only work because the driver silently converts, and a stricter
driver that binds typed parameters rejects them outright.

Found during a Rust port of the database pool where the driver does not
coerce automatically.

Each fix binds the value with the type the column actually declares,
rather than depending on driver-specific coercion. All changes are
behaviour-preserving on psycopg2 and sqlite.

There is also a fix to the multi-writer id-gen tests where we forgot to
commit. This is tangential, but was found during the same effort.

  ### Changes

- **`device_lists_remote_extremeties.stream_id (TEXT)`** —
`_update_remote_device_list_cache_txn` (typed `stream_id: int`) bound an
int; store it as a string, matching the column and the sibling
`_update_remote_device_list_cache_entry_txn` (typed `stream_id: str`).
The old mismatch, when rejected, was swallowed inside the device-list
resync and hung `query_devices`.
- **`user_filters.filter_id (BIGINT)`** — `get_user_filter` bound the
raw `int | str` (a string, from sync requests). It already validates via
`int(filter_id)`; bind that int so it matches the column.
- **`rejections.last_check (TEXT)`** — both writers stored
`clock.time_msec()` (an int); store the timestamp as a string.
- **user-directory temp position** — the `populate_user_directory`
background update's staging column
`_temp_populate_user_directory_position.position` was `TEXT` but held an
int (read back into a `BIGINT` column). Declare it `BIGINT`. The temp
table is created and dropped within the background update, so there's no
migration.

- **`test_batched_state_group_storing`** — selected from
`state_group_edges` with a stringified `state_group`; bind the int
directly (the column is an integer).
- **Multi-writer id-generator tests** — constructing a
`MultiWriterIdGenerator` prunes stale `stream_positions` rows, but the
harness never committed, so the cleanup only survived because adbapi
keeps one connection per thread with its transaction open. Commit after
construction so it persists regardless of pool semantics (the delete is
legitimate work that should be committed anyway).
This commit is contained in:
Erik Johnston authored and GitHub committed 2026-07-10 10:38:43 +01:00
1 parent 9bbd974b3e
commit 87439e55b5
8 files changed
+26 -9

No files matched your search

+1
View File
@@ -0,0 +1 @@
Fix storage type mismatches where values were bound with a type that didn't match their database column.
+4 -1
View File
@@ -2019,7 +2019,10 @@ class DeviceWorkerStore(RoomMemberWorkerStore, EndToEndKeyWorkerStore):
txn,
table="device_lists_remote_extremeties",
keyvalues={"user_id": user_id},
values={"stream_id": stream_id},
# `stream_id` is a TEXT column, so store it as a string (this method
# takes an int) rather than relying on the driver to coerce it.
# (Ideally we'd fix the schema, but that is non-trivial)
values={"stream_id": str(stream_id)},
)
async def add_device_change_to_streams(
+4 -1
View File
@@ -3513,7 +3513,10 @@ class PersistEventsStore:
values={
"event_id": event_id,
"reason": reason,
"last_check": self._clock.time_msec(),
# `last_check` is a TEXT column, so store the timestamp as a
# string rather than relying on the driver to coerce an int.
# (Ideally we'd fix the schema, but that is non-trivial)
"last_check": str(self._clock.time_msec()),
},
)
@@ -2702,7 +2702,10 @@ class EventsWorkerStore(SQLBaseStore):
keyvalues={"event_id": event_id},
values={
"reason": rejection_reason,
"last_check": self.clock.time_msec(),
# `last_check` is a TEXT column, so store the timestamp as a
# string rather than relying on the driver to coerce an int.
# (Ideally we'd fix the schema, but that is non-trivial)
"last_check": str(self.clock.time_msec()),
},
)
self.db_pool.simple_update_txn(
+1 -1
View File
@@ -156,7 +156,7 @@ class FilteringWorkerStore(SQLBaseStore):
# filter_id is BIGINT UNSIGNED, so if it isn't a number, fail
# with a coherent error message rather than 500 M_UNKNOWN.
try:
int(filter_id)
filter_id = int(filter_id)
except ValueError:
raise SynapseError(400, "Invalid filter ID", Codes.INVALID_PARAM)
@@ -127,7 +127,7 @@ class UserDirectoryBackgroundUpdateStore(StateDeltasStore):
sql = f"""
CREATE TABLE IF NOT EXISTS {TEMP_TABLE}_position (
position TEXT NOT NULL
position BIGINT NOT NULL
)
"""
txn.execute(sql)
+10 -1
View File
@@ -78,7 +78,7 @@ class MultiWriterIdGeneratorBase(HomeserverTestCase):
writers: list[str] | None = None,
) -> MultiWriterIdGenerator:
def _create(conn: LoggingDatabaseConnection) -> MultiWriterIdGenerator:
return MultiWriterIdGenerator(
id_gen = MultiWriterIdGenerator(
db_conn=conn,
db=self.db_pool,
notifier=self.hs.get_replication_notifier(),
@@ -90,6 +90,15 @@ class MultiWriterIdGeneratorBase(HomeserverTestCase):
writers=writers or ["master"],
positive=self.positive,
)
# Constructing the generator prunes stale `stream_positions` rows
# (writers no longer in the config); commit so that persists for the
# next generator we create.
#
# Note we need to commit manually here as the generator is created
# in a `runWithConnection` call, which doesn't automatically
# commit/rollback.
conn.commit()
return id_gen
self.instances[instance_name] = self.get_success_or_raise(
self.db_pool.runWithConnection(_create)
+1 -3
View File
@@ -637,9 +637,7 @@ class StateStoreTestCase(HomeserverTestCase):
self.get_success(
self.store.db_pool.simple_select_list(
table="state_group_edges",
keyvalues={
"state_group": str(context.state_group_after_event)
},
keyvalues={"state_group": context.state_group_after_event},
retcols=("prev_state_group",),
)
),