Skip to main content

syn2mas/
migration.rs

1// Copyright 2024, 2025 New Vector Ltd.
2//
3// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
4// Please see LICENSE files in the repository root for full details.
5
6//! # Migration
7//!
8//! This module provides the high-level logic for performing the Synapse-to-MAS
9//! database migration.
10//!
11//! This module does not implement any of the safety checks that should be run
12//! *before* the migration.
13
14use std::time::Instant;
15
16use chrono::{DateTime, Utc};
17use compact_str::CompactString;
18use futures_util::{SinkExt, StreamExt as _, TryFutureExt, TryStreamExt as _};
19use mas_data_model::{Clock, UlidExt as _};
20use rand::{RngCore, SeedableRng};
21use thiserror::Error;
22use thiserror_ext::ContextInto;
23use tokio_util::sync::PollSender;
24use tracing::{Instrument as _, Level, info};
25use ulid::Ulid;
26use uuid::{NonNilUuid, Uuid};
27
28use crate::{
29    HashMap, ProgressCounter, RandomState, SynapseReader,
30    mas_writer::{
31        self, MasNewCompatAccessToken, MasNewCompatRefreshToken, MasNewCompatSession,
32        MasNewEmailThreepid, MasNewUnsupportedThreepid, MasNewUpstreamOauthLink, MasNewUser,
33        MasNewUserPassword, MasWriteBuffer, MasWriter,
34    },
35    progress::{EntityType, Progress},
36    synapse_reader::{
37        self, ExtractLocalpartError, FullUserId, SynapseAccessToken, SynapseDevice,
38        SynapseExternalId, SynapseRefreshableTokenPair, SynapseThreepid, SynapseUser,
39    },
40};
41
42#[derive(Debug, Error, ContextInto)]
43pub enum Error {
44    #[error("error when reading synapse DB ({context}): {source}")]
45    Synapse {
46        source: synapse_reader::Error,
47        context: String,
48    },
49    #[error("error when writing to MAS DB ({context}): {source}")]
50    Mas {
51        source: mas_writer::Error,
52        context: String,
53    },
54    #[error("failed to extract localpart of {user:?}: {source}")]
55    ExtractLocalpart {
56        source: ExtractLocalpartError,
57        user: FullUserId,
58    },
59    #[error("channel closed")]
60    ChannelClosed,
61
62    #[error("task failed ({context}): {source}")]
63    Join {
64        source: tokio::task::JoinError,
65        context: String,
66    },
67
68    #[error("user {user} was not found for migration but a row in {table} was found for them")]
69    MissingUserFromDependentTable { table: String, user: FullUserId },
70    #[error(
71        "missing a mapping for the auth provider with ID {synapse_id:?} (used by {user} and maybe other users)"
72    )]
73    MissingAuthProviderMapping {
74        /// `auth_provider` ID of the provider in Synapse, for which we have no
75        /// mapping
76        synapse_id: String,
77        /// a user that is using this auth provider
78        user: FullUserId,
79    },
80}
81
82bitflags::bitflags! {
83    #[derive(Debug, Clone, Copy)]
84    struct UserFlags: u8 {
85        const IS_SYNAPSE_ADMIN = 0b0000_0001;
86        const IS_DEACTIVATED = 0b0000_0010;
87        const IS_GUEST = 0b0000_0100;
88        const IS_APPSERVICE = 0b0000_1000;
89    }
90}
91
92impl UserFlags {
93    const fn is_deactivated(self) -> bool {
94        self.contains(UserFlags::IS_DEACTIVATED)
95    }
96
97    const fn is_guest(self) -> bool {
98        self.contains(UserFlags::IS_GUEST)
99    }
100
101    const fn is_synapse_admin(self) -> bool {
102        self.contains(UserFlags::IS_SYNAPSE_ADMIN)
103    }
104
105    const fn is_appservice(self) -> bool {
106        self.contains(UserFlags::IS_APPSERVICE)
107    }
108}
109
110#[derive(Debug, Clone, Copy)]
111struct UserInfo {
112    mas_user_id: Option<NonNilUuid>,
113    flags: UserFlags,
114}
115
116struct MigrationState {
117    /// The server name we're migrating from
118    server_name: String,
119
120    /// Lookup table from user localpart to that user's infos
121    users: HashMap<CompactString, UserInfo>,
122
123    /// Mapping of MAS user ID + device ID to a MAS compat session ID.
124    devices_to_compat_sessions: HashMap<(NonNilUuid, CompactString), Uuid>,
125
126    /// A mapping of Synapse external ID providers to MAS upstream OAuth 2.0
127    /// provider ID
128    provider_id_mapping: std::collections::HashMap<String, Uuid>,
129}
130
131/// Performs a migration from Synapse's database to MAS' database.
132///
133/// # Panics
134///
135/// - If there are more than `usize::MAX` users
136///
137/// # Errors
138///
139/// Errors are returned under the following circumstances:
140///
141/// - An underlying database access error, either to MAS or to Synapse.
142/// - Invalid data in the Synapse database.
143#[expect(clippy::implicit_hasher)]
144#[expect(clippy::too_many_arguments)]
145pub async fn migrate(
146    mut synapse: SynapseReader<'_>,
147    mas: MasWriter,
148    server_name: String,
149    clock: &dyn Clock,
150    rng: &mut impl RngCore,
151    provider_id_mapping: std::collections::HashMap<String, Uuid>,
152    progress: &Progress,
153    ignore_missing_auth_providers: bool,
154) -> Result<(), Error> {
155    let counts = synapse.count_rows().await.into_synapse("counting users")?;
156
157    let state = MigrationState {
158        server_name,
159        // We oversize the hashmaps, as the estimates are innaccurate, and we would like to avoid
160        // reallocations.
161        users: HashMap::with_capacity_and_hasher(counts.users * 9 / 8, RandomState::default()),
162        devices_to_compat_sessions: HashMap::with_capacity_and_hasher(
163            counts.devices * 9 / 8,
164            RandomState::default(),
165        ),
166        provider_id_mapping,
167    };
168
169    let progress_counter = progress.migrating_data(EntityType::Users, counts.users);
170    let (mas, state) = migrate_users(&mut synapse, mas, state, rng, progress_counter).await?;
171
172    let progress_counter = progress.migrating_data(EntityType::ThreePids, counts.threepids);
173    let (mas, state) = migrate_threepids(&mut synapse, mas, rng, state, progress_counter).await?;
174
175    let progress_counter = progress.migrating_data(EntityType::ExternalIds, counts.external_ids);
176    let (mas, state) = migrate_external_ids(
177        &mut synapse,
178        mas,
179        rng,
180        state,
181        progress_counter,
182        ignore_missing_auth_providers,
183    )
184    .await?;
185
186    let progress_counter = progress.migrating_data(
187        EntityType::NonRefreshableAccessTokens,
188        counts.access_tokens - counts.refresh_tokens,
189    );
190    let (mas, state) =
191        migrate_unrefreshable_access_tokens(&mut synapse, mas, clock, rng, state, progress_counter)
192            .await?;
193
194    let progress_counter =
195        progress.migrating_data(EntityType::RefreshableTokens, counts.refresh_tokens);
196    let (mas, state) =
197        migrate_refreshable_token_pairs(&mut synapse, mas, clock, rng, state, progress_counter)
198            .await?;
199
200    let progress_counter = progress.migrating_data(EntityType::Devices, counts.devices);
201    let (mas, _state) = migrate_devices(&mut synapse, mas, rng, state, progress_counter).await?;
202
203    synapse
204        .finish()
205        .await
206        .into_synapse("failed to close Synapse reader")?;
207
208    mas.finish(progress)
209        .await
210        .into_mas("failed to finalise MAS database")?;
211
212    Ok(())
213}
214
215#[tracing::instrument(skip_all, level = Level::INFO)]
216async fn migrate_users(
217    synapse: &mut SynapseReader<'_>,
218    mut mas: MasWriter,
219    mut state: MigrationState,
220    rng: &mut impl RngCore,
221    progress_counter: ProgressCounter,
222) -> Result<(MasWriter, MigrationState), Error> {
223    let start = Instant::now();
224    let progress_counter_ = progress_counter.clone();
225
226    let (tx, mut rx) = tokio::sync::mpsc::channel::<SynapseUser>(100 * 1024);
227
228    // create a new RNG seeded from the passed RNG so that we can move it into the
229    // spawned task
230    let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng");
231    let task = tokio::spawn(
232        async move {
233            let mut user_buffer = MasWriteBuffer::new(&mas);
234            let mut password_buffer = MasWriteBuffer::new(&mas);
235
236            while let Some(user) = rx.recv().await {
237                // Handling an edge case: some AS users may have invalid localparts containing
238                // extra `:` characters. These users are ignored and a warning is logged.
239                if user.appservice_id.is_some()
240                    && user
241                        .name
242                        .0
243                        .strip_suffix(&format!(":{}", state.server_name))
244                        .is_some_and(|localpart| localpart.contains(':'))
245                {
246                    tracing::warn!("AS user {} has invalid localpart, ignoring!", user.name.0);
247                    continue;
248                }
249
250                let (mas_user, mas_password_opt) =
251                    transform_user(&user, &state.server_name, &mut rng)?;
252
253                let mut flags = UserFlags::empty();
254                if bool::from(user.admin) {
255                    flags |= UserFlags::IS_SYNAPSE_ADMIN;
256                }
257                if bool::from(user.deactivated) {
258                    flags |= UserFlags::IS_DEACTIVATED;
259                }
260                if bool::from(user.is_guest) {
261                    flags |= UserFlags::IS_GUEST;
262                }
263                if user.appservice_id.is_some() {
264                    flags |= UserFlags::IS_APPSERVICE;
265
266                    progress_counter.increment_skipped();
267
268                    // Special case for appservice users: we don't insert them into the database
269                    // We just record the user's information in the state and continue
270                    state.users.insert(
271                        CompactString::new(&mas_user.username),
272                        UserInfo {
273                            mas_user_id: None,
274                            flags,
275                        },
276                    );
277                    continue;
278                }
279
280                state.users.insert(
281                    CompactString::new(&mas_user.username),
282                    UserInfo {
283                        mas_user_id: Some(mas_user.user_id),
284                        flags,
285                    },
286                );
287
288                user_buffer
289                    .write(&mut mas, mas_user)
290                    .await
291                    .into_mas("writing user")?;
292
293                if let Some(mas_password) = mas_password_opt {
294                    password_buffer
295                        .write(&mut mas, mas_password)
296                        .await
297                        .into_mas("writing password")?;
298                }
299
300                progress_counter.increment_migrated();
301            }
302
303            user_buffer
304                .finish(&mut mas)
305                .await
306                .into_mas("writing users")?;
307            password_buffer
308                .finish(&mut mas)
309                .await
310                .into_mas("writing passwords")?;
311
312            Ok((mas, state))
313        }
314        .instrument(tracing::info_span!("ingest_task")),
315    );
316
317    // In case this has an error, we still want to join the task, so we look at the
318    // error later
319    let res = synapse
320        .read_users()
321        .map_err(|e| e.into_synapse("reading users"))
322        .forward(PollSender::new(tx).sink_map_err(|_| Error::ChannelClosed))
323        .inspect_err(|e| tracing::error!(error = e as &dyn std::error::Error))
324        .await;
325
326    let (mas, state) = task.await.into_join("user write task")??;
327
328    res?;
329
330    info!(
331        "{} users migrated ({} skipped) in {:.1}s",
332        progress_counter_.migrated(),
333        progress_counter_.skipped(),
334        Instant::now().duration_since(start).as_secs_f64()
335    );
336
337    Ok((mas, state))
338}
339
340#[tracing::instrument(skip_all, level = Level::INFO)]
341async fn migrate_threepids(
342    synapse: &mut SynapseReader<'_>,
343    mut mas: MasWriter,
344    rng: &mut impl RngCore,
345    state: MigrationState,
346    progress_counter: ProgressCounter,
347) -> Result<(MasWriter, MigrationState), Error> {
348    let start = Instant::now();
349    let progress_counter_ = progress_counter.clone();
350
351    let (tx, mut rx) = tokio::sync::mpsc::channel::<SynapseThreepid>(100 * 1024);
352
353    // create a new RNG seeded from the passed RNG so that we can move it into the
354    // spawned task
355    let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng");
356    let task = tokio::spawn(
357        async move {
358            let mut email_buffer = MasWriteBuffer::new(&mas);
359            let mut unsupported_buffer = MasWriteBuffer::new(&mas);
360
361            while let Some(threepid) = rx.recv().await {
362                let SynapseThreepid {
363                    user_id: synapse_user_id,
364                    medium,
365                    address,
366                    added_at,
367                } = threepid;
368                let created_at: DateTime<Utc> = added_at.into();
369
370                let username = synapse_user_id
371                    .extract_localpart(&state.server_name)
372                    .into_extract_localpart(synapse_user_id.clone())?
373                    .to_owned();
374                let Some(user_infos) = state.users.get(username.as_str()).copied() else {
375                    return Err(Error::MissingUserFromDependentTable {
376                        table: "user_threepids".to_owned(),
377                        user: synapse_user_id,
378                    });
379                };
380
381                let Some(mas_user_id) = user_infos.mas_user_id else {
382                    progress_counter.increment_skipped();
383                    continue;
384                };
385
386                if medium == "email" {
387                    email_buffer
388                        .write(
389                            &mut mas,
390                            MasNewEmailThreepid {
391                                user_id: mas_user_id,
392                                user_email_id: Uuid::from(Ulid::from_datetime_with_rng(
393                                    created_at, &mut rng,
394                                )),
395                                email: address,
396                                created_at,
397                            },
398                        )
399                        .await
400                        .into_mas("writing email")?;
401                } else {
402                    unsupported_buffer
403                        .write(
404                            &mut mas,
405                            MasNewUnsupportedThreepid {
406                                user_id: mas_user_id,
407                                medium,
408                                address,
409                                created_at,
410                            },
411                        )
412                        .await
413                        .into_mas("writing unsupported threepid")?;
414                }
415
416                progress_counter.increment_migrated();
417            }
418
419            email_buffer
420                .finish(&mut mas)
421                .await
422                .into_mas("writing email threepids")?;
423            unsupported_buffer
424                .finish(&mut mas)
425                .await
426                .into_mas("writing unsupported threepids")?;
427
428            Ok((mas, state))
429        }
430        .instrument(tracing::info_span!("ingest_task")),
431    );
432
433    // In case this has an error, we still want to join the task, so we look at the
434    // error later
435    let res = synapse
436        .read_threepids()
437        .map_err(|e| e.into_synapse("reading threepids"))
438        .forward(PollSender::new(tx).sink_map_err(|_| Error::ChannelClosed))
439        .inspect_err(|e| tracing::error!(error = e as &dyn std::error::Error))
440        .await;
441
442    let (mas, state) = task.await.into_join("threepid write task")??;
443
444    res?;
445
446    info!(
447        "{} third-party IDs migrated ({} skipped) in {:.1}s",
448        progress_counter_.migrated(),
449        progress_counter_.skipped(),
450        Instant::now().duration_since(start).as_secs_f64()
451    );
452
453    Ok((mas, state))
454}
455
456#[tracing::instrument(skip_all, level = Level::INFO)]
457async fn migrate_external_ids(
458    synapse: &mut SynapseReader<'_>,
459    mut mas: MasWriter,
460    rng: &mut impl RngCore,
461    state: MigrationState,
462    progress_counter: ProgressCounter,
463    ignore_missing_auth_providers: bool,
464) -> Result<(MasWriter, MigrationState), Error> {
465    let start = Instant::now();
466    let progress_counter_ = progress_counter.clone();
467
468    let (tx, mut rx) = tokio::sync::mpsc::channel::<SynapseExternalId>(100 * 1024);
469
470    // create a new RNG seeded from the passed RNG so that we can move it into the
471    // spawned task
472    let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng");
473    let task = tokio::spawn(
474        async move {
475            let mut write_buffer = MasWriteBuffer::new(&mas);
476
477            while let Some(extid) = rx.recv().await {
478                let SynapseExternalId {
479                    user_id: synapse_user_id,
480                    auth_provider,
481                    external_id: subject,
482                } = extid;
483                let username = synapse_user_id
484                    .extract_localpart(&state.server_name)
485                    .into_extract_localpart(synapse_user_id.clone())?
486                    .to_owned();
487                let Some(user_infos) = state.users.get(username.as_str()).copied() else {
488                    return Err(Error::MissingUserFromDependentTable {
489                        table: "user_external_ids".to_owned(),
490                        user: synapse_user_id,
491                    });
492                };
493
494                let Some(mas_user_id) = user_infos.mas_user_id else {
495                    progress_counter.increment_skipped();
496                    continue;
497                };
498
499                let Some(&upstream_provider_id) = state.provider_id_mapping.get(&auth_provider)
500                else {
501                    if ignore_missing_auth_providers {
502                        progress_counter.increment_skipped();
503                        continue;
504                    }
505                    return Err(Error::MissingAuthProviderMapping {
506                        synapse_id: auth_provider,
507                        user: synapse_user_id,
508                    });
509                };
510
511                // To save having to store user creation times, extract it from the ULID
512                // This gives millisecond precision — good enough.
513                let user_created_ts = Ulid::from(mas_user_id.get()).datetime_utc();
514
515                let link_id: Uuid = Ulid::from_datetime_with_rng(user_created_ts, &mut rng).into();
516
517                write_buffer
518                    .write(
519                        &mut mas,
520                        MasNewUpstreamOauthLink {
521                            link_id,
522                            user_id: mas_user_id,
523                            upstream_provider_id,
524                            subject,
525                            created_at: user_created_ts,
526                        },
527                    )
528                    .await
529                    .into_mas("failed to write upstream link")?;
530
531                progress_counter.increment_migrated();
532            }
533
534            write_buffer
535                .finish(&mut mas)
536                .await
537                .into_mas("writing upstream links")?;
538
539            Ok((mas, state))
540        }
541        .instrument(tracing::info_span!("ingest_task")),
542    );
543
544    // In case this has an error, we still want to join the task, so we look at the
545    // error later
546    let res = synapse
547        .read_user_external_ids()
548        .map_err(|e| e.into_synapse("reading external ID"))
549        .forward(PollSender::new(tx).sink_map_err(|_| Error::ChannelClosed))
550        .inspect_err(|e| tracing::error!(error = e as &dyn std::error::Error))
551        .await;
552
553    let (mas, state) = task.await.into_join("external IDs write task")??;
554
555    res?;
556
557    info!(
558        "{} upstream links (external IDs) migrated ({} skipped) in {:.1}s",
559        progress_counter_.migrated(),
560        progress_counter_.skipped(),
561        Instant::now().duration_since(start).as_secs_f64()
562    );
563
564    Ok((mas, state))
565}
566
567/// Migrate devices from Synapse to MAS (as compat sessions).
568///
569/// In order to get the right session creation timestamps, the access tokens
570/// must counterintuitively be migrated first, with the ULIDs passed in as
571/// `devices`.
572///
573/// This is because only access tokens store a timestamp that in any way
574/// resembles a creation timestamp.
575#[tracing::instrument(skip_all, level = Level::INFO)]
576async fn migrate_devices(
577    synapse: &mut SynapseReader<'_>,
578    mut mas: MasWriter,
579    rng: &mut impl RngCore,
580    mut state: MigrationState,
581    progress_counter: ProgressCounter,
582) -> Result<(MasWriter, MigrationState), Error> {
583    let start = Instant::now();
584    let progress_counter_ = progress_counter.clone();
585
586    let (tx, mut rx) = tokio::sync::mpsc::channel(100 * 1024);
587
588    // create a new RNG seeded from the passed RNG so that we can move it into the
589    // spawned task
590    let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng");
591    let task = tokio::spawn(
592        async move {
593            let mut write_buffer = MasWriteBuffer::new(&mas);
594
595            while let Some(device) = rx.recv().await {
596                let SynapseDevice {
597                    user_id: synapse_user_id,
598                    device_id,
599                    display_name,
600                    last_seen,
601                    ip,
602                    user_agent,
603                } = device;
604                let username = synapse_user_id
605                    .extract_localpart(&state.server_name)
606                    .into_extract_localpart(synapse_user_id.clone())?
607                    .to_owned();
608                let Some(user_infos) = state.users.get(username.as_str()).copied() else {
609                    return Err(Error::MissingUserFromDependentTable {
610                        table: "devices".to_owned(),
611                        user: synapse_user_id,
612                    });
613                };
614
615                let Some(mas_user_id) = user_infos.mas_user_id else {
616                    progress_counter.increment_skipped();
617                    continue;
618                };
619
620                if user_infos.flags.is_deactivated()
621                    || user_infos.flags.is_guest()
622                    || user_infos.flags.is_appservice()
623                {
624                    continue;
625                }
626
627                let session_id = *state
628                    .devices_to_compat_sessions
629                    .entry((mas_user_id, CompactString::new(&device_id)))
630                    .or_insert_with(|| {
631                        // We don't have a creation time for this device (as it has no access
632                        // token), so use now as a least-evil fallback.
633                        Ulid::from_datetime_with_rng(
634                            #[expect(clippy::disallowed_methods)]
635                            Utc::now(),
636                            &mut rng,
637                        )
638                        .into()
639                    });
640                let created_at = Ulid::from(session_id).datetime_utc();
641
642                // As we're using a real IP type in the MAS database, it is possible
643                // that we encounter invalid IP addresses in the Synapse database.
644                // In that case, we should ignore them, but still log a warning.
645                // One special case: Synapse will record '-' as IP in some cases, we don't want
646                // to log about those
647                let last_active_ip = ip.filter(|ip| ip != "-").and_then(|ip| {
648                    ip.parse()
649                        .map_err(|e| {
650                            tracing::warn!(
651                                error = &e as &dyn std::error::Error,
652                                mxid = %synapse_user_id,
653                                %device_id,
654                                %ip,
655                                "Failed to parse device IP, ignoring"
656                            );
657                        })
658                        .ok()
659                });
660
661                write_buffer
662                    .write(
663                        &mut mas,
664                        MasNewCompatSession {
665                            session_id,
666                            user_id: mas_user_id,
667                            device_id: Some(device_id),
668                            human_name: display_name,
669                            created_at,
670                            is_synapse_admin: user_infos.flags.is_synapse_admin(),
671                            last_active_at: last_seen.map(DateTime::from),
672                            last_active_ip,
673                            user_agent,
674                        },
675                    )
676                    .await
677                    .into_mas("writing compat sessions")?;
678
679                progress_counter.increment_migrated();
680            }
681
682            write_buffer
683                .finish(&mut mas)
684                .await
685                .into_mas("writing compat sessions")?;
686
687            Ok((mas, state))
688        }
689        .instrument(tracing::info_span!("ingest_task")),
690    );
691
692    // In case this has an error, we still want to join the task, so we look at the
693    // error later
694    let res = synapse
695        .read_devices()
696        .map_err(|e| e.into_synapse("reading devices"))
697        .forward(PollSender::new(tx).sink_map_err(|_| Error::ChannelClosed))
698        .inspect_err(|e| tracing::error!(error = e as &dyn std::error::Error))
699        .await;
700
701    let (mas, state) = task.await.into_join("device write task")??;
702
703    res?;
704
705    info!(
706        "{} devices migrated ({} skipped) in {:.1}s",
707        progress_counter_.migrated(),
708        progress_counter_.skipped(),
709        Instant::now().duration_since(start).as_secs_f64()
710    );
711
712    Ok((mas, state))
713}
714
715/// Migrates unrefreshable access tokens (those without an associated refresh
716/// token). Some of these may be deviceless.
717#[tracing::instrument(skip_all, level = Level::INFO)]
718async fn migrate_unrefreshable_access_tokens(
719    synapse: &mut SynapseReader<'_>,
720    mut mas: MasWriter,
721    clock: &dyn Clock,
722    rng: &mut impl RngCore,
723    mut state: MigrationState,
724    progress_counter: ProgressCounter,
725) -> Result<(MasWriter, MigrationState), Error> {
726    let start = Instant::now();
727    let progress_counter_ = progress_counter.clone();
728
729    let (tx, mut rx) = tokio::sync::mpsc::channel(100 * 1024);
730
731    let now = clock.now();
732    // create a new RNG seeded from the passed RNG so that we can move it into the
733    // spawned task
734    let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng");
735    let task = tokio::spawn(
736        async move {
737            let mut write_buffer = MasWriteBuffer::new(&mas);
738            let mut deviceless_session_write_buffer = MasWriteBuffer::new(&mas);
739
740            while let Some(token) = rx.recv().await {
741                let SynapseAccessToken {
742                    user_id: synapse_user_id,
743                    device_id,
744                    token,
745                    valid_until_ms,
746                    last_validated,
747                } = token;
748                let username = synapse_user_id
749                    .extract_localpart(&state.server_name)
750                    .into_extract_localpart(synapse_user_id.clone())?
751                    .to_owned();
752                let Some(user_infos) = state.users.get(username.as_str()).copied() else {
753                    return Err(Error::MissingUserFromDependentTable {
754                        table: "access_tokens".to_owned(),
755                        user: synapse_user_id,
756                    });
757                };
758
759                let Some(mas_user_id) = user_infos.mas_user_id else {
760                    progress_counter.increment_skipped();
761                    continue;
762                };
763
764                if user_infos.flags.is_deactivated()
765                    || user_infos.flags.is_guest()
766                    || user_infos.flags.is_appservice()
767                {
768                    progress_counter.increment_skipped();
769                    continue;
770                }
771
772                // It's not always accurate, but last_validated is *often* the creation time of
773                // the device If we don't have one, then use the current time as a
774                // fallback.
775                let created_at = last_validated.map_or_else(|| now, DateTime::from);
776
777                let session_id = if let Some(device_id) = device_id {
778                    // Use the existing device_id if this is the second token for a device
779                    *state
780                        .devices_to_compat_sessions
781                        .entry((mas_user_id, CompactString::new(&device_id)))
782                        .or_insert_with(|| {
783                            Uuid::from(Ulid::from_datetime_with_rng(created_at, &mut rng))
784                        })
785                } else {
786                    // If this is a deviceless access token, create a deviceless compat session
787                    // for it (since otherwise we won't create one whilst migrating devices)
788                    let deviceless_session_id =
789                        Uuid::from(Ulid::from_datetime_with_rng(created_at, &mut rng));
790
791                    deviceless_session_write_buffer
792                        .write(
793                            &mut mas,
794                            MasNewCompatSession {
795                                session_id: deviceless_session_id,
796                                user_id: mas_user_id,
797                                device_id: None,
798                                human_name: None,
799                                created_at,
800                                is_synapse_admin: false,
801                                last_active_at: None,
802                                last_active_ip: None,
803                                user_agent: None,
804                            },
805                        )
806                        .await
807                        .into_mas("failed to write deviceless compat sessions")?;
808
809                    deviceless_session_id
810                };
811
812                let token_id = Uuid::from(Ulid::from_datetime_with_rng(created_at, &mut rng));
813
814                write_buffer
815                    .write(
816                        &mut mas,
817                        MasNewCompatAccessToken {
818                            token_id,
819                            session_id,
820                            access_token: token,
821                            created_at,
822                            expires_at: valid_until_ms.map(DateTime::from),
823                        },
824                    )
825                    .await
826                    .into_mas("writing compat access tokens")?;
827
828                progress_counter.increment_migrated();
829            }
830            write_buffer
831                .finish(&mut mas)
832                .await
833                .into_mas("writing compat access tokens")?;
834            deviceless_session_write_buffer
835                .finish(&mut mas)
836                .await
837                .into_mas("writing deviceless compat sessions")?;
838
839            Ok((mas, state))
840        }
841        .instrument(tracing::info_span!("ingest_task")),
842    );
843
844    // In case this has an error, we still want to join the task, so we look at the
845    // error later
846    let res = synapse
847        .read_unrefreshable_access_tokens()
848        .map_err(|e| e.into_synapse("reading tokens"))
849        .forward(PollSender::new(tx).sink_map_err(|_| Error::ChannelClosed))
850        .inspect_err(|e| tracing::error!(error = e as &dyn std::error::Error))
851        .await;
852
853    let (mas, state) = task.await.into_join("token write task")??;
854
855    res?;
856
857    info!(
858        "{} non-refreshable access tokens migrated ({} skipped) in {:.1}s",
859        progress_counter_.migrated(),
860        progress_counter_.skipped(),
861        Instant::now().duration_since(start).as_secs_f64()
862    );
863
864    Ok((mas, state))
865}
866
867/// Migrates (access token, refresh token) pairs.
868/// Does not migrate non-refreshable access tokens.
869#[tracing::instrument(skip_all, level = Level::INFO)]
870async fn migrate_refreshable_token_pairs(
871    synapse: &mut SynapseReader<'_>,
872    mut mas: MasWriter,
873    clock: &dyn Clock,
874    rng: &mut impl RngCore,
875    mut state: MigrationState,
876    progress_counter: ProgressCounter,
877) -> Result<(MasWriter, MigrationState), Error> {
878    let start = Instant::now();
879    let progress_counter_ = progress_counter.clone();
880
881    let (tx, mut rx) = tokio::sync::mpsc::channel::<SynapseRefreshableTokenPair>(100 * 1024);
882
883    // create a new RNG seeded from the passed RNG so that we can move it into the
884    // spawned task
885    let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng");
886    let now = clock.now();
887    let task = tokio::spawn(
888        async move {
889            let mut access_token_write_buffer = MasWriteBuffer::new(&mas);
890            let mut refresh_token_write_buffer = MasWriteBuffer::new(&mas);
891
892            while let Some(token) = rx.recv().await {
893                let SynapseRefreshableTokenPair {
894                    user_id: synapse_user_id,
895                    device_id,
896                    access_token,
897                    refresh_token,
898                    valid_until_ms,
899                    last_validated,
900                } = token;
901
902                let username = synapse_user_id
903                    .extract_localpart(&state.server_name)
904                    .into_extract_localpart(synapse_user_id.clone())?
905                    .to_owned();
906                let Some(user_infos) = state.users.get(username.as_str()).copied() else {
907                    return Err(Error::MissingUserFromDependentTable {
908                        table: "refresh_tokens".to_owned(),
909                        user: synapse_user_id,
910                    });
911                };
912
913                let Some(mas_user_id) = user_infos.mas_user_id else {
914                    progress_counter.increment_skipped();
915                    continue;
916                };
917
918                if user_infos.flags.is_deactivated()
919                    || user_infos.flags.is_guest()
920                    || user_infos.flags.is_appservice()
921                {
922                    progress_counter.increment_skipped();
923                    continue;
924                }
925
926                // It's not always accurate, but last_validated is *often* the creation time of
927                // the device If we don't have one, then use the current time as a
928                // fallback.
929                let created_at = last_validated.map_or_else(|| now, DateTime::from);
930
931                // Use the existing device_id if this is the second token for a device
932                let session_id = *state
933                    .devices_to_compat_sessions
934                    .entry((mas_user_id, CompactString::new(&device_id)))
935                    .or_insert_with(|| {
936                        Uuid::from(Ulid::from_datetime_with_rng(created_at, &mut rng))
937                    });
938
939                let access_token_id =
940                    Uuid::from(Ulid::from_datetime_with_rng(created_at, &mut rng));
941                let refresh_token_id =
942                    Uuid::from(Ulid::from_datetime_with_rng(created_at, &mut rng));
943
944                access_token_write_buffer
945                    .write(
946                        &mut mas,
947                        MasNewCompatAccessToken {
948                            token_id: access_token_id,
949                            session_id,
950                            access_token,
951                            created_at,
952                            expires_at: valid_until_ms.map(DateTime::from),
953                        },
954                    )
955                    .await
956                    .into_mas("writing compat access tokens")?;
957                refresh_token_write_buffer
958                    .write(
959                        &mut mas,
960                        MasNewCompatRefreshToken {
961                            refresh_token_id,
962                            session_id,
963                            access_token_id,
964                            refresh_token,
965                            created_at,
966                        },
967                    )
968                    .await
969                    .into_mas("writing compat refresh tokens")?;
970
971                progress_counter.increment_migrated();
972            }
973
974            access_token_write_buffer
975                .finish(&mut mas)
976                .await
977                .into_mas("writing compat access tokens")?;
978
979            refresh_token_write_buffer
980                .finish(&mut mas)
981                .await
982                .into_mas("writing compat refresh tokens")?;
983            Ok((mas, state))
984        }
985        .instrument(tracing::info_span!("ingest_task")),
986    );
987
988    // In case this has an error, we still want to join the task, so we look at the
989    // error later
990    let res = synapse
991        .read_refreshable_token_pairs()
992        .map_err(|e| e.into_synapse("reading refresh token pairs"))
993        .forward(PollSender::new(tx).sink_map_err(|_| Error::ChannelClosed))
994        .inspect_err(|e| tracing::error!(error = e as &dyn std::error::Error))
995        .await;
996
997    let (mas, state) = task.await.into_join("refresh token write task")??;
998
999    res?;
1000
1001    info!(
1002        "{} refreshable token pairs migrated ({} skipped) in {:.1}s",
1003        progress_counter_.migrated(),
1004        progress_counter_.skipped(),
1005        Instant::now().duration_since(start).as_secs_f64()
1006    );
1007
1008    Ok((mas, state))
1009}
1010
1011fn transform_user(
1012    user: &SynapseUser,
1013    server_name: &str,
1014    rng: &mut impl RngCore,
1015) -> Result<(MasNewUser, Option<MasNewUserPassword>), Error> {
1016    let username = user
1017        .name
1018        .extract_localpart(server_name)
1019        .into_extract_localpart(user.name.clone())?
1020        .to_owned();
1021
1022    let user_id = Uuid::from(Ulid::from_datetime_with_rng(
1023        DateTime::<Utc>::from(user.creation_ts),
1024        rng,
1025    ))
1026    .try_into()
1027    .expect("ULID generation lead to a nil UUID, this is a bug!");
1028
1029    let new_user = MasNewUser {
1030        user_id,
1031        username,
1032        created_at: user.creation_ts.into(),
1033        locked_at: user.locked.then_some(user.creation_ts.into()),
1034        deactivated_at: bool::from(user.deactivated).then_some(user.creation_ts.into()),
1035        can_request_admin: bool::from(user.admin),
1036        is_guest: bool::from(user.is_guest),
1037    };
1038
1039    let mas_password = user
1040        .password_hash
1041        .clone()
1042        .map(|password_hash| MasNewUserPassword {
1043            user_password_id: Uuid::from(Ulid::from_datetime_with_rng(
1044                DateTime::<Utc>::from(user.creation_ts),
1045                rng,
1046            )),
1047            user_id: new_user.user_id,
1048            hashed_password: password_hash,
1049            created_at: new_user.created_at,
1050        });
1051
1052    Ok((new_user, mas_password))
1053}