Skip to content

Speed up marking device list changes as converted - #20098

Merged
erikjohnston merged 7 commits into
developfrom
erikj/device_list_conversion_lag
Aug 17, 2026
Merged

erikjohnston merged 7 commits into
developfrom
erikj/device_list_conversion_lag

Conversation

@erikjohnston

Copy link
Copy Markdown
Member

The conversion of device_lists_changes_in_room rows into device_lists_outbound_pokes is DB-bound on
mark_redundant_device_lists_pokes. The UPDATE uses the (room_id, stream_id) index, so each call scans the unconverted backlog, getting slower the further behind the conversion is.

Add a partial index matching the query via a background update, and skip the (safe-to-skip) UPDATE until the index has been built. Also add a metric reporting how far behind the conversion is, using the existing inserted_ts column.

Fixes https://github.com/element-hq/backend-internal/issues/286

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Schema Diff

Please check that this looks as expected!

common (sqlite)

Unchanged

common (postgres)

Unchanged

main (sqlite)

--- /tmp/schema_diff_az2vpfi0/before/main/full_schemas/9999/full.sql.sqlite	2001-05-25 12:42:42.000000000 +0000
+++ /tmp/schema_diff_az2vpfi0/after/main/full_schemas/9999/full.sql.sqlite	2001-05-25 12:42:42.000000000 +0000
@@ -1227,20 +1227,21 @@
 CREATE INDEX local_media_repository_sha256 ON local_media_repository (sha256) WHERE sha256 IS NOT NULL;
 CREATE INDEX remote_media_cache_sha256 ON remote_media_cache (sha256) WHERE sha256 IS NOT NULL;
 CREATE INDEX sliding_sync_membership_snapshots_membership_event_id_idx ON sliding_sync_membership_snapshots (membership_event_id) ;
 CREATE INDEX sliding_sync_membership_snapshots_user_id_stream_ordering ON sliding_sync_membership_snapshots (user_id, event_stream_ordering) ;
 CREATE INDEX device_federation_inbox_received_ts_index ON device_federation_inbox (received_ts) ;
 CREATE UNIQUE INDEX event_txn_id_device_id_txn_id2 ON event_txn_id_device_id (user_id, device_id, room_id, txn_id) ;
 CREATE UNIQUE INDEX delayed_events_idx ON delayed_events (delay_id) ;
 CREATE INDEX device_lists_changes_in_room_inserted_ts_idx ON device_lists_changes_in_room (inserted_ts) WHERE inserted_ts IS NOT NULL;
 CREATE INDEX sliding_sync_connections_last_used_ts_idx ON sliding_sync_connections (last_used_ts) WHERE last_used_ts IS NOT NULL;
 CREATE INDEX sliding_sync_connection_lazy_members_conn_pos_idx ON sliding_sync_connection_lazy_members (connection_position) WHERE connection_position IS NOT NULL;
+CREATE INDEX device_lists_changes_in_room_unconverted_idx ON device_lists_changes_in_room (user_id, device_id, room_id, stream_id) WHERE NOT converted_to_destinations;
 /* WARNING: Script requires that SQLITE_DBCONFIG_DEFENSIVE be disabled */
 INSERT INTO appservice_stream_position VALUES('X',0);
 INSERT INTO federation_stream_position VALUES('federation',-1,'master');
 INSERT INTO federation_stream_position VALUES('events',-1,'master');
 INSERT INTO event_push_summary_stream_ordering VALUES('X',0);
 INSERT INTO user_directory_stream_pos VALUES('X',-1);
 INSERT INTO stats_incremental_position VALUES('X',1);
 INSERT INTO event_push_summary_last_receipt_stream_id VALUES('X',0);
 INSERT INTO device_lists_changes_converted_stream_position VALUES('X',1,'',NULL);
 INSERT INTO room_forgetter_stream_pos VALUES('X',0);

main (postgres)

--- /tmp/schema_diff_az2vpfi0/before/main/full_schemas/9999/full.sql.postgres	2001-05-25 12:42:42.000000000 +0000
+++ /tmp/schema_diff_az2vpfi0/after/main/full_schemas/9999/full.sql.postgres	2001-05-25 12:42:42.000000000 +0000
@@ -1654,20 +1654,21 @@
 CREATE INDEX device_auth_providers_devices ON device_auth_providers USING btree (user_id, device_id);
 CREATE INDEX device_auth_providers_sessions ON device_auth_providers USING btree (auth_provider_id, auth_provider_session_id);
 CREATE INDEX device_federation_inbox_received_ts_index ON device_federation_inbox USING btree (received_ts);
 CREATE INDEX device_federation_inbox_sender_id ON device_federation_inbox USING btree (origin, message_id);
 CREATE INDEX device_federation_outbox_destination_id ON device_federation_outbox USING btree (destination, stream_id);
 CREATE INDEX device_federation_outbox_id ON device_federation_outbox USING btree (stream_id);
 CREATE INDEX device_inbox_stream_id_user_id ON device_inbox USING btree (stream_id, user_id);
 CREATE INDEX device_inbox_user_stream_id ON device_inbox USING btree (user_id, device_id, stream_id);
 CREATE INDEX device_lists_changes_in_room_by_room_idx ON device_lists_changes_in_room USING btree (room_id, stream_id);
 CREATE INDEX device_lists_changes_in_room_inserted_ts_idx ON device_lists_changes_in_room USING btree (inserted_ts) WHERE (inserted_ts IS NOT NULL);
+CREATE INDEX device_lists_changes_in_room_unconverted_idx ON device_lists_changes_in_room USING btree (user_id, device_id, room_id, stream_id) WHERE (NOT converted_to_destinations);
 CREATE UNIQUE INDEX device_lists_changes_in_stream_id ON device_lists_changes_in_room USING btree (stream_id, room_id);
 CREATE INDEX device_lists_changes_in_stream_id_unconverted ON device_lists_changes_in_room USING btree (stream_id) WHERE (NOT converted_to_destinations);
 CREATE UNIQUE INDEX device_lists_outbound_last_success_unique_idx ON device_lists_outbound_last_success USING btree (destination, user_id);
 CREATE INDEX device_lists_outbound_pokes_id ON device_lists_outbound_pokes USING btree (destination, stream_id);
 CREATE INDEX device_lists_outbound_pokes_stream ON device_lists_outbound_pokes USING btree (stream_id);
 CREATE INDEX device_lists_outbound_pokes_user ON device_lists_outbound_pokes USING btree (destination, user_id);
 CREATE UNIQUE INDEX device_lists_remote_cache_unique_id ON device_lists_remote_cache USING btree (user_id, device_id);
 CREATE UNIQUE INDEX device_lists_remote_extremeties_unique_idx ON device_lists_remote_extremeties USING btree (user_id);
 CREATE UNIQUE INDEX device_lists_remote_pending_user_device_id ON device_lists_remote_pending USING btree (user_id, device_id);
 CREATE UNIQUE INDEX device_lists_remote_resync_idx ON device_lists_remote_resync USING btree (user_id);

state (sqlite)

Unchanged

state (postgres)

Unchanged

@erikjohnston
erikjohnston force-pushed the erikj/device_list_conversion_lag branch 3 times, most recently from 3c92d01 to 6ac75e2 Compare August 12, 2026 14:56
@MadLittleMods MadLittleMods added A-Device-List-Tracking A-to-device-messages EDU messages sent exactly once to a specific set of devices. Related to E2EE A-Database labels Aug 12, 2026
The conversion of `device_lists_changes_in_room` rows into
`device_lists_outbound_pokes` is DB-bound on
`mark_redundant_device_lists_pokes`: its UPDATE has no usable index, so
each call scans the unconverted backlog, getting slower the further
behind the conversion is.

Add a partial index matching the query via a background update, and
skip the (safe-to-skip) UPDATE until the index has been built. Also add
a metric reporting how far behind the conversion is, using the existing
`inserted_ts` column.
@erikjohnston
erikjohnston force-pushed the erikj/device_list_conversion_lag branch from 6ac75e2 to a029580 Compare August 12, 2026 15:21
@erikjohnston
erikjohnston marked this pull request as ready for review August 12, 2026 16:03
@erikjohnston
erikjohnston requested a review from a team as a code owner August 12, 2026 16:03
@MadLittleMods MadLittleMods added the A-Metrics (Prometheus metrics) label Aug 12, 2026
Comment thread synapse/handlers/device.py Outdated
Comment thread synapse/storage/databases/main/devices.py
Comment on lines +2757 to +2764
# Add an index to speed up `mark_redundant_device_lists_pokes`.
self.db_pool.updates.register_background_index_update(
BG_UPDATE_ADD_UNCONVERTED_IDX,
index_name="device_lists_changes_in_room_unconverted_idx",
table="device_lists_changes_in_room",
columns=["user_id", "device_id", "room_id", "stream_id"],
where_clause="NOT converted_to_destinations",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we should just drop mark_redundant_device_lists_pokes as an optimization?

You mentioned that you "don't think it catches many redundant pokes anyway" (internal room)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is useful if you have the same device coming and going, but it's not necessary.

We could probably delete it entirely but I think a) we'd want to investigate it, and b) I think we still would want an index like the above anyway.

erikjohnston and others added 4 commits August 13, 2026 09:02
Reports the difference between the current device lists stream position
and `device_lists_changes_converted_stream_position`, alongside the
existing time-based lag metric.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Thn52seV72QdxwDx5etBPM
Read the converted stream position and the oldest unconverted row in
one transaction, rather than one (or two) each.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Thn52seV72QdxwDx5etBPM
"synapse_device_lists_changes_conversion_stream_lag",
"Number of stream IDs between the current device lists stream position and the position converted to outbound federation pokes",
labelnames=[SERVER_NAME_LABEL],
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MadLittleMods I've also added this so you can see how many stream IDs we're behind on.

This helps because if there is a large batch of device updates in a short period of time the lag time might increase even though it's catching up.

reivilibre added a commit that referenced this pull request Aug 13, 2026
Issue spotted in: #20098

Follows: #20027

We already use `faketime` for Postgres, but I forgot that the SQLite
schema delta would have the same problem
and somehow tuned it out of the preview diff on the original PR.

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
Comment thread synapse/handlers/device.py Outdated

return not update_exists

async def have_completed_background_updates(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can do the same lookup and update of _completed_background_updates here as well

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's already handled via self._all_done?

@MadLittleMods MadLittleMods Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is have_completed_background_updates(update_names) not has_completed_background_updates()

Updating our cache in have_completed_background_updates(update_names) makes just as much sense as has_completed_background_update(update_name) like we are doing

Comment thread synapse/storage/databases/main/devices.py Outdated
@erikjohnston
erikjohnston force-pushed the erikj/device_list_conversion_lag branch from 413d3b2 to edaddcc Compare August 17, 2026 09:34
@erikjohnston
erikjohnston merged commit 94a5f2a into develop Aug 17, 2026
45 of 47 checks passed
@erikjohnston
erikjohnston deleted the erikj/device_list_conversion_lag branch August 17, 2026 11:52
@erikjohnston

Copy link
Copy Markdown
Member Author

Thanks @MadLittleMods !

reivilibre pushed a commit that referenced this pull request Aug 18, 2026
…database (#20120)

### Background

On `matrix.org`, since 2026-08-15, we are seeing the database CPU being
saturated more than usual
([grafana](https://grafana.matrix.org/d/rYdddlPWk/node-exporter?orgId=1&from=2026-08-10T23:23:14.237Z&to=2026-08-17T23:23:14.237Z&timezone=browser&var-DS_PROMETHEUS=default&var-job=machine&var-node=matrix-db-01.matrix.org:9100&var-diskdevices=%5Ba-z%5D%2B%7Cnvme%5B0-9%5D%2Bn%5B0-9%5D%2B&refresh=1m&viewPanel=panel-77))

<img width="919" height="265" alt="CPU of database server"
src="https://github.com/user-attachments/assets/9a43cf3c-bf9a-4ade-b70e-0bf0fb53f0ef"
/>


@reivilibre
[found](https://matrix.to/#/!yHWhpxlXVaLcsgDUKb:matrix.org/$fUMf60IbFocpEuJNbmbEzFiCyKCFnAl4I4XpB27DUQs?via=banzan.uk&via=element.io&via=matrix.org)
`mark_as_sent_devices_by_remote` spiking in the `DB transactions by
total txn time` graph
([grafana](https://grafana.matrix.org/d/000000012/synapse?var-bucket_size=$__auto&orgId=1&from=2026-08-10T22:26:07.336Z&to=2026-08-17T22:26:07.336Z&timezone=browser&var-datasource=default&var-instance=matrix.org&var-job=synapse_federation_sender&var-index=$__all&showCategory=Thresholds&viewPanel=panel-11))

<img width="919" height="269" alt="'mark_as_sent_devices_by_remote'
spiking in the 'DB transactions by total txn time' graph"
src="https://github.com/user-attachments/assets/9336ce29-c045-4940-9c46-9e4906300f2d"
/>

And we indeed see a bunch of time being spent on
`mark_as_sent_devices_by_remote` by looking at `pg_stat_statements`. The
top two queries we're spending CPU on are the `SELECT` and `DELETE`
statements in
[`mark_as_sent_devices_by_remote`](https://github.com/element-hq/synapse/blob/94a5f2afb36a4afdc36813fd1c24b9a1c4aec252/synapse/storage/databases/main/devices.py#L922-L950).


We also did some related work in this area recently with
#20098 although it was
tackling a different bottle-neck.


### This PR

This PRs combines the two separate `SELECT` and `DELETE` queries which
touch the same data into one `DELETE ... RETURNING ...` query. this
means we get to save the cost of one of those statements (less CPU on
the database) and fewer statements per transaction (less round-trips)
means connections turn over faster, and can move on to process the next
thing.

This is a micro-optimization I spotted while reading
[`_mark_as_sent_devices_by_remote_txn`](https://github.com/element-hq/synapse/blob/develop/synapse/storage/databases/main/devices.py#L922-L950)
rather than a structural fix. I'm sure there are even better things to
do to where we could even avoid this kind of work altogether but this
seemed like a quick win especially given how much this particular
transaction is saturating the database.

Doing things faster doesn't necessarily mean we solve the
saturated/starved CPU problem but it does mean the same task costs less
CPU.

From the `pg_stat_statements` samples above, we can expect to be up to
~27% more efficient with database CPU on this code path (derived from
the `cpus_busy` numbers above `22.932 / (22.932 + 61.956)`). We're
removing the `SELECT` (`61.956` `cpus_busy`) but I still expect the
`DELETE` (`22.932` `cpus_busy`) to take on a similar cost as I'm sure
it's a warm cache situation between them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Database A-Device-List-Tracking A-Metrics (Prometheus metrics) A-to-device-messages EDU messages sent exactly once to a specific set of devices. Related to E2EE

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants