Skip to main content

arminius_lib_db_account_data/
lib.rs

1// SPDX-FileCopyrightText: 2025 BWI GmbH
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Queries relating to account data
6//!
7//!
8//! # Example for tags
9//!
10//! Content of a `room_account_data` row to store room tags:
11//!
12//!
13//! | user_id  | room_id                               | data_type   |                       data                          |
14//! -----------|---------------------------------------|-------------|-----------------------------------------------------|
15//! | 1        | `!kZrfCE0UDTT3PjUosE:localhost:44199` | `m.tag`     | `{"tags": {"u.valid.ordered.tag": {"order": 0.3}}}` |
16//!
17//!
18//! A Matrix client requesting its tag would receive:
19//!
20//! ```json
21//! {
22//!     "tags": {
23//!         "u.valid.ordered.tag": {
24//!             "order": 0.3
25//!         }
26//!     }
27//! }
28//! ```
29//!
30//! Which corresponds to the following `m.tag` Matrix event:
31//!
32//! ```json
33//! {
34//!     "content": {
35//!         "tags": {
36//!             "u.valid.ordered.tag": {
37//!                 "order": 0.3
38//!             }
39//!         }
40//!     },
41//!     "type": "m.tag"
42//! }
43//! ```
44
45use arminius_lib_db::{Database, DatabaseError, ResultExt as _, TransactedDb};
46use arminius_lib_model::types::StreamId;
47use ruma::{
48	OwnedRoomId, OwnedUserId, RoomId, UserId,
49	api::client::filter::{Filter, RoomEventFilter},
50	events::{
51		GlobalAccountDataEventType, RoomAccountDataEvent, RoomAccountDataEventContent,
52		RoomAccountDataEventType, room::member::MembershipState,
53	},
54};
55use serde::de::DeserializeOwned;
56use serde_json::json;
57use sqlx::postgres::PgQueryResult;
58
59/// Used to query room account data events along their room ID.
60#[derive(Debug)]
61pub struct SyncRoomAccountDataEvent {
62	/// The room ID the event belongs to
63	pub room_id: OwnedRoomId,
64	/// The user's membership in the event's room, at the moment it was fetched.
65	pub membership: MembershipState,
66	/// The stream ID of that event.
67	pub stream_id: StreamId,
68	/// The actual event as JSON
69	pub event: serde_json::Value,
70}
71
72/// Used to query global account data events, along their stream ID.
73#[derive(Debug, PartialEq)]
74pub struct SyncGlobalAccountDataEvent {
75	/// The stream ID of that event.
76	pub stream_id: StreamId,
77	/// The actual event as JSON
78	pub event: serde_json::Value,
79}
80
81/// Extension trait to acquire a per-user advisory lock for account data
82/// writes, held for the duration of the current transaction.
83pub trait AccountDataLock: AccountDataQueries {
84	/// Acquire a transaction-scoped, per-user advisory lock for account data
85	/// writes.
86	///
87	/// This serializes every writer performing a read-modify-write on a
88	/// user's account data (e.g. the tag handlers, which read the `m.tag`
89	/// blob, modify it in Rust, and upsert it back). It must be called
90	/// *before* the read that a subsequent write depends on, so that the
91	/// whole read-then-write sequence is atomic with respect to other
92	/// writers. Without it, two concurrent writers can both read the same
93	/// blob and the loser's update is silently overwritten (lost update,
94	/// see issue #688).
95	///
96	/// `pg_advisory_xact_lock` is preferred over `SELECT ... FOR UPDATE` on
97	/// the `room_account_data` row: `FOR UPDATE` only locks rows that already
98	/// exist, so two concurrent writers inserting the *first* row for a
99	/// `(user, room, type)` key would still race. The advisory lock also
100	/// releases automatically on commit or rollback and is re-entrant within
101	/// the same session.
102	///
103	/// The two-key `(room_account_data_oid, users.nid)` form is deliberate —
104	/// do NOT reduce it to `pg_advisory_xact_lock(nid::bigint)`. A small
105	/// `nid` in the `bigint` form is stored as `classid = 0`, the space
106	/// `max_safe_seq` scans for in-flight stream id values (see
107	/// `00_locking_sequence.sql`). Since account data writes allocate a
108	/// stream id in the same transaction, a `classid = 0` lock would collapse
109	/// the sync watermark to the user's `nid` and stall /sync. Namespacing by
110	/// table OID keeps the lock out of that scan.
111	///
112	/// Locking on `users.nid` serializes all account data writes of one user
113	/// (across rooms and data types). That is coarser than strictly needed,
114	/// but account data writes are low-frequency and this keeps the lock key
115	/// a plain `int4` without hashing.
116	///
117	/// If the user does not exist no lock is taken — handlers only run for
118	/// authenticated (i.e. existing) users.
119	#[tracing::instrument(skip(self))]
120	async fn lock_room_account_data(&mut self, user_id: &UserId) -> Result<(), DatabaseError> {
121		sqlx::query_scalar!(
122			"SELECT pg_advisory_xact_lock(
123			    (SELECT oid::int FROM pg_class WHERE relname = 'room_account_data' AND relkind = 'r'),
124			    nid
125			)
126			FROM users WHERE user_id = $1",
127			user_id.as_str(),
128		)
129		.fetch_optional(self.connection().await?)
130		.await?;
131
132		Ok(())
133	}
134
135	/// Retrieve room account data for a user in a specific room, taking the
136	/// per-user account data lock ([`Self::lock_room_account_data`]) first.
137	///
138	/// Use this instead of `fetch_room_account_data` whenever the fetched
139	/// value is modified and written back within the same transaction
140	/// (read-modify-write), so concurrent writers are serialized and cannot
141	/// overwrite each other's updates (#688).
142	#[tracing::instrument(skip(self))]
143	async fn fetch_room_account_data_for_update<C>(
144		&mut self,
145		user_id: &UserId,
146		room_id: &RoomId,
147		event_type: &RoomAccountDataEventType,
148	) -> Result<Option<RoomAccountDataEvent<C>>, DatabaseError>
149	where
150		C: RoomAccountDataEventContent + DeserializeOwned,
151		Self: Sized,
152	{
153		self.lock_room_account_data(user_id).await?;
154
155		self.fetch_room_account_data(user_id, room_id, event_type).await
156	}
157}
158
159/// The lock is transaction-scoped (`pg_advisory_xact_lock` releases on commit
160/// or rollback), so it is only implemented for [`TransactedDb`]: taking it on
161/// a plain connection would release it again right after the statement,
162/// providing no protection.
163impl AccountDataLock for TransactedDb<'_> {}
164
165/// Queries relating to account data
166// Queries are usually used as extension traits so suggest `import as _`.
167#[rust_analyzer::prefer_underscore_import]
168pub trait AccountDataQueries: Database {
169	/// Inserts or updates room account data for a user in a specific room.
170	#[tracing::instrument(skip(self))]
171	async fn upsert_room_account_data(
172		&mut self,
173		user_id: &UserId,
174		room_id: &RoomId,
175		event_type: &RoomAccountDataEventType,
176		data: serde_json::Value,
177	) -> Result<PgQueryResult, DatabaseError> {
178		sqlx::query!(
179			"
180			INSERT INTO room_account_data (user_nid, room_nid, data_type, data)
181				VALUES (
182					(SELECT nid FROM users WHERE user_id = $1),
183					(SELECT nid FROM rooms WHERE room_id = $2),
184					$3,
185					$4
186				)
187			ON CONFLICT (user_nid, room_nid, data_type)
188				DO UPDATE SET
189					data = EXCLUDED.data,
190					stream_id = EXCLUDED.stream_id
191			",
192			user_id.as_str(),
193			room_id.as_str(),
194			event_type.to_string(),
195			data,
196		)
197		.execute(self.connection().await?)
198		.await
199		.map_err(Into::into)
200	}
201
202	/// Inserts or updates global account data for a user.
203	#[tracing::instrument(skip(self))]
204	async fn upsert_global_account_data(
205		&mut self,
206		user_id: &UserId,
207		event_type: &GlobalAccountDataEventType,
208		data: serde_json::Value,
209	) -> Result<PgQueryResult, DatabaseError> {
210		sqlx::query!(
211			"
212			 INSERT INTO global_account_data (user_nid, data_type, data)
213			 	VALUES (
214			 		(SELECT nid FROM users WHERE user_id = $1),
215			 		$2,
216			 		$3
217			 	)
218			 ON CONFLICT (user_nid, data_type)
219			 	DO UPDATE SET
220			 		data = EXCLUDED.data,
221					stream_id = EXCLUDED.stream_id
222			",
223			user_id.as_str(),
224			event_type.to_string(),
225			data,
226		)
227		.execute(self.connection().await?)
228		.await
229		.map_err(Into::into)
230	}
231
232	/// Retrieve room account data for a user in a specific room.
233	#[tracing::instrument(skip(self))]
234	async fn fetch_room_account_data<C: RoomAccountDataEventContent + DeserializeOwned>(
235		&mut self,
236		user_id: &UserId,
237		room_id: &RoomId,
238		event_type: &RoomAccountDataEventType,
239	) -> Result<Option<RoomAccountDataEvent<C>>, DatabaseError> {
240		self.fetch_room_account_data_untyped(user_id, room_id, event_type)
241			.await?
242			.map(|data| {
243				let event_content = serde_json::from_value::<C>(data)
244					.invalid_data("Invalid room account data event in database")?;
245
246				Ok(RoomAccountDataEvent::new(event_content))
247			})
248			.transpose()
249	}
250
251	/// Retrieve room account data for a user in a specific room.
252	///
253	/// This function, as opposed to [`Self::fetch_room_account_data`], is to be
254	/// used when you do not need to know which variant of
255	/// [`RoomAccountDataEvent`] you have in the caller.
256	#[tracing::instrument(skip(self))]
257	async fn fetch_room_account_data_untyped(
258		&mut self,
259		user_id: &UserId,
260		room_id: &RoomId,
261		event_type: &RoomAccountDataEventType,
262	) -> Result<Option<serde_json::Value>, DatabaseError> {
263		Ok(sqlx::query!(
264			"
265			SELECT
266				room_account_data.data
267			FROM
268				room_account_data
269				JOIN users ON room_account_data.user_nid = users.nid
270				JOIN rooms ON room_account_data.room_nid = rooms.nid
271			WHERE
272				users.user_id = $1
273				AND room_id = $2
274				AND data_type = $3
275			",
276			user_id.as_str(),
277			room_id.as_str(),
278			event_type.to_string(),
279		)
280		.fetch_optional(self.connection().await?)
281		.await?
282		.map(|row| row.data))
283	}
284
285	/// Imports a global account-data row, preserving the source stream_id.
286	/// Idempotent: an existing (user, data_type) row is left untouched.
287	///
288	/// Preserving the stream_id bypasses `account_data_streamids`, so the
289	/// sequence is advanced past the imported value in the same transaction.
290	/// This keeps the "next auto-assigned stream_id never collides with an
291	/// existing row" invariant: the import leaves the database healthy on its
292	/// own, with no external sequence reset to remember.
293	#[tracing::instrument(skip(self))]
294	async fn import_global_account_data(
295		&mut self,
296		user_id: &UserId,
297		data_type: &str,
298		data: serde_json::Value,
299		stream_id: i64,
300	) -> Result<bool, DatabaseError> {
301		let result = sqlx::query!(
302			r#"
303			INSERT INTO global_account_data (user_nid, data_type, data, stream_id)
304				VALUES ((
305						SELECT
306							nid
307						FROM
308							users
309						WHERE
310							user_id = $1), $2, $3, $4)
311			ON CONFLICT (user_nid,
312				data_type)
313				DO NOTHING
314			"#,
315			user_id.as_str(),
316			data_type,
317			data,
318			stream_id,
319		)
320		.execute(self.connection().await?)
321		.await?;
322		advance_account_data_sequence(self, stream_id).await?;
323		Ok(result.rows_affected() > 0)
324	}
325
326	/// Imports a room account-data row, preserving the source stream_id.
327	/// Idempotent: an existing (user, room, data_type) row is left untouched.
328	///
329	/// Shares `account_data_streamids` with
330	/// [`Self::import_global_account_data`] and, like it, advances the
331	/// sequence past the preserved stream_id in the same transaction so the
332	/// import leaves the database healthy on its own.
333	#[tracing::instrument(skip(self))]
334	async fn import_room_account_data(
335		&mut self,
336		user_id: &UserId,
337		room_id: &RoomId,
338		data_type: &str,
339		data: serde_json::Value,
340		stream_id: i64,
341	) -> Result<bool, DatabaseError> {
342		let result = sqlx::query!(
343			r#"
344			INSERT INTO room_account_data (user_nid, room_nid, data_type, data, stream_id)
345			VALUES
346				((
347						SELECT
348							nid
349						FROM
350							users
351						WHERE
352							user_id = $1), (
353							SELECT
354								nid
355							FROM
356								rooms
357							WHERE
358								room_id = $2), $3, $4, $5)
359				ON CONFLICT (user_nid,
360					room_nid,
361					data_type)
362				DO NOTHING
363			"#,
364			user_id.as_str(),
365			room_id.as_str(),
366			data_type,
367			data,
368			stream_id,
369		)
370		.execute(self.connection().await?)
371		.await?;
372		advance_account_data_sequence(self, stream_id).await?;
373		Ok(result.rows_affected() > 0)
374	}
375
376	/// Fetch global account data for a user based on the event type.
377	#[tracing::instrument(skip(self))]
378	async fn fetch_global_account_data_untyped(
379		&mut self,
380		user_id: &UserId,
381		event_type: &GlobalAccountDataEventType,
382	) -> Result<Option<serde_json::Value>, DatabaseError> {
383		Ok(sqlx::query!(
384			"
385			SELECT
386				global_account_data.data
387			FROM
388				global_account_data
389				JOIN users ON global_account_data.user_nid = users.nid
390			WHERE
391				users.user_id = $1
392				AND data_type = $2
393			",
394			user_id.as_str(),
395			event_type.to_string(),
396		)
397		.fetch_optional(self.connection().await?)
398		.await?
399		.map(|row| row.data))
400	}
401
402	/// Fetches any entry in the `global_account_data` table which `stream_id`
403	/// strictly higher than `from_stream_id`.
404	#[tracing::instrument(skip(self))]
405	async fn fetch_sync_global_account_data_events(
406		&mut self,
407		user_id: &UserId,
408		from_stream_id: StreamId,
409		filter: Option<&Filter>,
410	) -> Result<Vec<SyncGlobalAccountDataEvent>, DatabaseError> {
411		let types = filter.and_then(|filter| filter.types.clone());
412		let not_types = filter.map(|filter| filter.not_types.clone()).unwrap_or_default();
413		Ok(sqlx::query!(
414			"
415			SELECT
416				stream_id,
417				data_type,
418				data
419			FROM
420				global_account_data
421				JOIN users ON global_account_data.user_nid = users.nid
422			WHERE
423				users.user_id = $1
424				AND stream_id > $2
425				AND ($3::text[] IS NULL OR data_type::text = ANY ($3::text[]))
426				AND NOT (data_type = ANY ($4::text[]))
427				AND stream_id <= max_safe_seq ('account_data_streamids')
428			",
429			user_id.to_string(),
430			from_stream_id,
431			types.as_deref(),
432			&not_types
433		)
434		.fetch_all(self.connection().await?)
435		.await?
436		.into_iter()
437		.map(|row| SyncGlobalAccountDataEvent {
438			stream_id: row.stream_id,
439			event: json!({
440				"type": row.data_type,
441				"content": row.data
442			}),
443		})
444		.collect())
445	}
446
447	/// Fetches any entry in the `room_account_data` table which `stream_id`
448	/// strictly higher than `from_stream_id`.
449	#[tracing::instrument(skip(self))]
450	async fn fetch_sync_room_account_data_events(
451		&mut self,
452		user_id: &OwnedUserId,
453		from_stream_id: StreamId,
454		filter: Option<&RoomEventFilter>,
455	) -> Result<Vec<SyncRoomAccountDataEvent>, DatabaseError> {
456		let types = filter.and_then(|filter| filter.types.clone());
457		let not_types = filter.map(|filter| filter.not_types.clone()).unwrap_or_default();
458		sqlx::query!(
459			r#"
460			SELECT
461				room_membership_state.room_id AS "room_id!",
462				room_membership_state.membership AS "membership!",
463				stream_id,
464				data_type,
465				data
466			FROM
467				room_account_data
468				JOIN room_membership_state ($1) ON room_account_data.room_nid = room_membership_state.room_nid
469			WHERE
470				room_membership_state.membership IN ('join', 'leave')
471				AND stream_id > $2
472				AND ($3::text[] IS NULL
473					OR data_type::text = ANY ($3::text[]))
474				AND NOT (data_type = ANY ($4::text[]))
475				AND stream_id <= max_safe_seq ('account_data_streamids')
476			"#,
477			user_id.to_string(),
478			from_stream_id,
479			types.as_deref(),
480			&not_types
481		)
482		.fetch_all(self.connection().await?)
483		.await?
484		.into_iter()
485		.map(|row| {
486			let room_id = RoomId::parse(row.room_id).invalid_data("Invalid room ID in database")?;
487
488			Ok(SyncRoomAccountDataEvent {
489				room_id,
490				membership: MembershipState::from(row.membership),
491				stream_id: row.stream_id,
492				event: json!({
493					"type": row.data_type,
494					"content": row.data
495				}),
496			})
497		})
498		.collect::<Result<Vec<_>, _>>()
499	}
500}
501
502impl<T> AccountDataQueries for T where T: Database {}
503
504/// Advances `account_data_streamids` so its last value covers `stream_id`,
505/// never moving it backwards. Called by the account-data importers after they
506/// insert a row carrying a preserved (sequence-bypassing) stream_id, so the
507/// next `nextval` cannot hand out an id that already exists.
508///
509/// `setval` is non-transactional, so on a rolled-back import batch the sequence
510/// may stay advanced, that only ever skips ids forward, which is harmless
511/// (stream_ids need only be monotonic, not gap-free).
512async fn advance_account_data_sequence<T: Database + ?Sized>(
513	db: &mut T,
514	stream_id: i64,
515) -> Result<(), DatabaseError> {
516	sqlx::query_scalar!(
517		"SELECT setval(
518			'account_data_streamids',
519			GREATEST((SELECT last_value FROM account_data_streamids), $1),
520			true)",
521		stream_id,
522	)
523	.fetch_one(db.connection().await?)
524	.await?;
525	Ok(())
526}