TM-SGNL-iOS/SignalServiceKit/StorageService/StorageServiceUnknownFieldMigrator.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

498 lines
23 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
/**
* Utility class to simplify the transition when we start handling a previously-unknown storage
* service field for the first time.
*
* ---
* All this is rather complicated. Lets start with an example:
*
* Say we have some setting for dark mode (isDarkMode) on iOS and Desktop.
* Desktop has been syncing the setting to storage service. iOS has not. We want to start syncing it.
* Locally we have three states: UNSET, FALSE, TRUE.
*
* If we just naively added the field to our local proto definition, we'd recognize it the next time
* we read from storage service, but our naive merging logic would overwrite our local state
* with the state we see in storage service. Uh oh! We should respect local state!
*
* Ok so maybe we prefer local state for the primary, so we ignore the value when we read and
* keep our local state instead. We do that, write our local state, Desktop reads it, and then the user
* updates the value on Desktop. Next time we read from storage service on iOS...we drop the
* value the user set on Desktop in favor of our local value! Uh oh!
*
* In short, what we need is to ignore the value on reads UNTIL we get a chance to write at least once,
* and then we can start respecting the value on reads.
* This class solves this problem generally.
*
* ---
*
* Now, how does this class actually work? The scaffolding handles all the hooks for you so
* that you can write a "migration" that does some subset of three things:
* 1. ``MergeUnknownFields`` - do any special one-time handling the _first_ time we learn about
* some previously-unknown proto field. This lets you e.g. do any one-time merging of remote state
* into local state. In the isDarkMode example, we would take this opportunity to overwrite a local
* UNSET value with any value in storage service.
*
* Next are two methods that run on every record we read/write until we get the chance to successfully
* write to storage service once. This lets us intercept records _before_ the standard merging code runs
* and perform modifications while we are in this "in between" state when we first handle unknown fields.
* 2. ``InterceptRemoteManifest`` - Take a record from a storage service manifest we fetched
* remotely and are about to merge with local state, and modify it _before_ we merge.
* In the isDarkMode example, we would overwrite the remote state with local state, so that the merge
* code always sees the remote state and local state as the same and changes nothing.
* 3. ``InterceptLocalManifest`` - Take a record from a storage service manifest we generated
* locally and are about to _write_ to a remote, and modify it _before_ we write it.
* In the isDarkMode example we would do nothing in this step, but if we had some more complex merging
* logic that reconciles local and remote state, you could imagine this coming in handy.
*
* So the general flow is:
* 1. Have some unknown field (isDarkMode) in storage service
* 2. Update to a build that knows about this field for the first time
* 3. Once, the _very first_ time on launch of this new build: run ``MergeUnknownFields`` providing
* all records with unknown fields. (May be empty. See ##Unknown Field Default Values## below).
* 4. If we read from storage service: pass every read record to ``InterceptRemoteManifest`` before
* merging locally. (We pretend isDarkMode remote state is the same as local state so we never overwrite local state yet).
* 5. If we write to storage service: pass every generated record to ``InterceptLocalManifest`` before
* writing remotely.
* 6. Repeat 4 and 5 for every read/write until some write succeeds (step 3 is never repeated!)
* 7. Succesfully write to storage service
* 8. Done! Migration marked complete.
*
* To see this in pseudocode for the isDarkMode example, see the ``MigrationId.noOpExample`` migration below.
*
* ---
*
* There are some gotchas with ``MergeUnknownFields``.
*
* ##Unknown Field Default Values##
*
* One quirk of unknown fields in protos: if a field uses a primitive type (e.g. bool) and
* is set to its default value (e.g. false), the serialized representation of that proto simply
* omits the field entirely.
* When a client that is unaware of the field parses such a proto, it doesn't know to
* look for the field and doesn't see anything in the serialized bytes, so it has no idea
* there is an unknown field at all.
*
* This means our "records with unknown fields" actually means "records with unknown
* fields set to non-default values". So your ``MergeUnknownFields`` implementation
* **SHOULD NOT** assume it is getting every record that had an unknown field; it only
* gets those with non-default values. (And might actually be an empty array if all records
* use default values!)
*
* What this means in practice is you should NOT write for loops over the passed in records;
* instead you should loop over objects in our database (e.g. TSGroupThreads) and match those
* against any passed in records (e.g. by groupId) and, if not present, assume the default value.
*
* ##Triggering Storage Service Writes##
*
* Most of the time, if you're merging remote state in ``MergeUnknownFields``, you will want
* to write back the results of the merge to storage service. The way to do this is to mark any updated
* record as needing an update via normal mechanisms, e.g. for the local account record you would call
* `storageServiceManager.recordPendingLocalAccountUpdates()`
*/
public class StorageServiceUnknownFieldMigrator {
fileprivate enum MigrationId: UInt, CaseIterable {
case noOpExample = 0
case dontNotifyForMentionsIfMuted = 1
// MARK: - Migration Insertion Point
// Never, ever, ever insert another migration before an existing one.
// Increase the value only.
static var highestKnownValue: UInt {
return allCases.lazy.map(\.rawValue).max() ?? 0
}
}
fileprivate enum Actions<RecordType: MigrateableStorageServiceRecordType> {
typealias MergeUnknownFields = (_ records: [RecordType], _ isPrimaryDevice: Bool, _ tx: SDSAnyWriteTransaction) -> Void
typealias InterceptRemoteManifest = (RecordType, inout RecordType.Builder, _ isPrimaryDevice: Bool, _ tx: SDSAnyReadTransaction) -> Void
typealias InterceptLocalManifest = (RecordType, inout RecordType.Builder, _ isPrimaryDevice: Bool, _ tx: SDSAnyReadTransaction) -> Void
}
private static func registerMigrations(migrator: Migrator) {
// This is simply an example migration (commented out) for how you _would_ migrate
// if you had an isDarkMode setting that already existed both locally and in storage service
// that needed to be merged such that the local value, if set, "wins" on the primary.
// See doc comment on this class for context.
migrator.registerMigration(
.noOpExample,
record: StorageServiceProtoAccountRecord.self,
mergeUnknownFields: { accountRecords, isPrimaryDevice, tx in
/**
let isRemoteDarkMode = accountRecords.first?.isDarkMode ?? false
guard isPrimaryDevice else {
// Just take what we have in storage service.
localDarkModeSetting.set(isRemoteDarkMode, tx: tx)
}
switch (localDarkModeSetting.get(tx: tx)) {
case .TRUE, .FALSE:
// Ignore if set locally, schedule an update so we overwrite
// storage service with our local value.
NSObject.storageServiceManager.recordPendingLocalAccountUpdates()
case .UNSET:
// Just take what we have in storage service.
localDarkModeSetting.set(isRemoteDarkMode, tx: tx)
}
*/
},
interceptRemoteManifest: { accountRecord, accountRecordBuilder, isPrimaryDevice, tx in
/**
if isPrimaryDevice {
// Until we get the chance to write to storage service, we want to
// always use our local value. Overwrite the value to our local value
// so that the merge logic uses it.
accountRecordBuilder.setIsDarkMode(localDarkModeSetting.get(tx: tx))
}
*/
return
},
interceptLocalManifest: { accountRecord, accountRecordBuilder, isPrimaryDevice, tx in
/**
// Nothing to intercept for writes in this case
*/
return
}
)
migrator.registerMigration(
.dontNotifyForMentionsIfMuted,
record: StorageServiceProtoGroupV2Record.self,
mergeUnknownFields: { records, isPrimaryDevice, tx in
// groupId -> dontNotifyForMentionsIfMuted
var recordMap = [Data: Bool]()
records.forEach {
if let groupId = (try? GroupV2ContextInfo.deriveFrom(masterKeyData: $0.masterKey))?.groupId {
recordMap[groupId] = $0.dontNotifyForMentionsIfMuted
}
}
try? ThreadFinder().enumerateGroupThreads(transaction: tx) { groupThread -> Bool in
let remoteValue: TSThreadMentionNotificationMode =
(recordMap[groupThread.groupId] ?? false) ? .never : .always
if isPrimaryDevice {
// On primaries, only set if previously unset.
if groupThread.mentionNotificationMode == .default {
groupThread.updateWithMentionNotificationMode(remoteValue, wasLocallyInitiated: false, transaction: tx)
} else {
// Schedule an update so we put up our local state onto storageService.
SSKEnvironment.shared.storageServiceManagerRef.recordPendingUpdates(groupModel: groupThread.groupModel)
}
} else {
// On secondaries, take the value from storage service.
groupThread.updateWithMentionNotificationMode(remoteValue, wasLocallyInitiated: false, transaction: tx)
}
return true
}
},
interceptRemoteManifest: { record, recordBuilder, isPrimaryDevice, tx in
// when we read a remote group on the primary, override with our local
// value so we dont overwrite the local value with the remote's value
// until we've had the chance to write ourselves.
if isPrimaryDevice {
guard
let groupId = (try? GroupV2ContextInfo.deriveFrom(masterKeyData: record.masterKey))?.groupId,
let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: tx)
else {
return
}
switch groupThread.mentionNotificationMode {
case .default:
// Don't overwrite.
break
case .always:
recordBuilder.setDontNotifyForMentionsIfMuted(false)
case .never:
recordBuilder.setDontNotifyForMentionsIfMuted(true)
}
}
},
interceptLocalManifest: { record, recordBuilder, isPrimaryDevice, tx in
// Nothing to do
}
)
// MARK: - Migration Insertion Point
}
// MARK: - Public methods
// If you are just writing a new migration, you don't need to worry about these.
/// Check this before merging records with unknown fields; if true, call ``runMigrationsForRecordsWithUnknownFields``
public static func needsAnyUnknownFieldsMigrations(tx: SDSAnyReadTransaction) -> Bool {
return necessaryMigrations(forKey: Keys.lastRunUnknownFieldsMerge, tx: tx).isEmpty.negated
}
/// Check this before merging records from a remote manifest; if true, call ``interceptRemoteManifestBeforeMerging``
public static func shouldInterceptRemoteManifestBeforeMerging(tx: SDSAnyReadTransaction) -> Bool {
return necessaryMigrations(forKey: Keys.lastSuccessfulStorageServiceWrite, tx: tx).isEmpty.negated
}
/// Check this before uploading records generated locally; if true, call ``interceptLocalManifestBeforeUploading``
public static func shouldInterceptLocalManifestBeforeUploading(tx: SDSAnyReadTransaction) -> Bool {
return necessaryMigrations(forKey: Keys.lastSuccessfulStorageServiceWrite, tx: tx).isEmpty.negated
}
/// Call this after every succesful write of a manifest to Storage Service.
public static func didWriteToStorageService(tx: SDSAnyWriteTransaction) {
kvStore.setUInt(MigrationId.highestKnownValue, key: Keys.lastSuccessfulStorageServiceWrite, transaction: tx.asV2Write)
}
/// Given an array of every record from the latest synced manifest known to have unknown fields, runs any necessary migrations.
public static func runMigrationsForRecordsWithUnknownFields(
records: [any MigrateableStorageServiceRecordType],
tx: SDSAnyWriteTransaction
) {
return _runMigrationsForRecordsWithUnknownFields(records: records, tx: tx)
}
public static func interceptRemoteManifestBeforeMerging<RecordType>(
record: RecordType,
tx: SDSAnyReadTransaction
) -> RecordType {
guard let recordTypecast = record as? (any MigrateableStorageServiceRecordType) else {
// Not migrateable. Just no-op.
return record
}
return _interceptRemoteManifestBeforeMerging(record: recordTypecast, tx: tx) as! RecordType
}
public static func interceptLocalManifestBeforeUploading<RecordType>(
record: RecordType,
tx: SDSAnyReadTransaction
) -> RecordType {
guard let recordTypecast = record as? (any MigrateableStorageServiceRecordType) else {
// Not migrateable. Just no-op.
return record
}
return _interceptLocalManifestBeforeUploading(record: recordTypecast, tx: tx) as! RecordType
}
// MARK: - Private Implementation
private enum Keys {
// The value at this key is a UInt representing the highest known MigrationId
// at the time this device last merged unknown fields from a previously-stored
// storage service manifest. If this number is lower than the current highest known
// MigrationId, we should run any unknown field merging operation migrations higher
// than the stored value.
static let lastRunUnknownFieldsMerge = "lastRunUnknownFieldsMerge"
// The value at this key is a UInt representing the highest known MigrationId
// at the time that this device last succesfully updated the manifest in storage service.
// If this number is lower than the current highest known MigrationId, we should run any
// higher value migrations' Record mutation operations on every record we read and write
// locally.
static let lastSuccessfulStorageServiceWrite = "lastSuccessfulStorageServiceWrite"
}
private static var kvStore = KeyValueStore(collection: "StorageServiceUnknownFieldMigrator")
private class Migrator {
var migrations: [MigrationId: any StorageServiceUnknownFieldMigration] = [:]
func registerMigration<RecordType: MigrateableStorageServiceRecordType>(
_ migrationId: MigrationId,
record: RecordType.Type,
mergeUnknownFields: @escaping Actions<RecordType>.MergeUnknownFields,
interceptRemoteManifest: @escaping Actions<RecordType>.InterceptRemoteManifest,
interceptLocalManifest: @escaping Actions<RecordType>.InterceptLocalManifest
) {
let migration = StorageServiceUnknownFieldMigrationImpl(
id: migrationId,
mergeUnknownFields: mergeUnknownFields,
interceptRemoteManifest: interceptRemoteManifest,
interceptLocalManifest: interceptLocalManifest
)
migrations[migrationId] = migration
}
}
private static var _migrator: Migrator?
private static var migrator: Migrator {
if let _migrator {
return _migrator
}
let migrator = Migrator()
registerMigrations(migrator: migrator)
_migrator = migrator
return migrator
}
private static func necessaryMigrations(
forKey key: String,
tx: SDSAnyReadTransaction
) -> LazyFilterSequence<[MigrationId]> {
guard let latestMigrationId = kvStore.getUInt(key, transaction: tx.asV2Read) else {
// We've never run any migrations!
return MigrationId.allCases.lazy.filter { _ in true }
}
return MigrationId.allCases.lazy.filter { $0.rawValue > latestMigrationId }
}
private static func _runMigrationsForRecordsWithUnknownFields(
records: [any MigrateableStorageServiceRecordType],
tx: SDSAnyWriteTransaction
) {
let necessaryMigrations = Self.necessaryMigrations(forKey: Keys.lastRunUnknownFieldsMerge, tx: tx)
if necessaryMigrations.isEmpty {
return
}
guard let isPrimaryDevice = DependenciesBridge.shared.tsAccountManager.registrationState(tx: tx.asV2Read).isPrimaryDevice else {
// Not registered!
return
}
func doMergeUnknownFields<Migration: StorageServiceUnknownFieldMigration>(
records: [any MigrateableStorageServiceRecordType],
migration: Migration
) {
let filteredRecords = records.compactMap { $0 as? Migration.RecordType }
migration.mergeUnknownFields(filteredRecords, isPrimaryDevice, tx)
}
necessaryMigrations.forEach { migrationId in
guard let migration = migrator.migrations[migrationId] else {
return
}
doMergeUnknownFields(records: records, migration: migration)
}
kvStore.setUInt(MigrationId.highestKnownValue, key: Keys.lastRunUnknownFieldsMerge, transaction: tx.asV2Write)
}
private static func _interceptRemoteManifestBeforeMerging<RecordType: MigrateableStorageServiceRecordType>(
record: RecordType,
tx: SDSAnyReadTransaction
) -> RecordType {
let necessaryMigrations = Self.necessaryMigrations(forKey: Keys.lastSuccessfulStorageServiceWrite, tx: tx)
if necessaryMigrations.isEmpty {
return record
}
guard let isPrimaryDevice = DependenciesBridge.shared.tsAccountManager.registrationState(tx: tx.asV2Read).isPrimaryDevice else {
// Not registered!
return record
}
var _builder: RecordType.Builder?
func doModifyRemoteManifest<Migration: StorageServiceUnknownFieldMigration>(
migration: Migration
) {
guard RecordType.self == Migration.RecordType.self else {
return
}
let builder = _builder ?? record.asBuilder()
var typecastBuilder = builder as! Migration.RecordType.Builder
migration.interceptRemoteManifest(record as! Migration.RecordType, &typecastBuilder, isPrimaryDevice, tx)
_builder = (typecastBuilder as! RecordType.Builder)
}
necessaryMigrations.forEach { migrationId in
guard let migration = migrator.migrations[migrationId] else {
return
}
doModifyRemoteManifest(migration: migration)
}
if let builder = _builder {
return builder.buildInfallibly()
} else {
// It was unmodified.
return record
}
}
private static func _interceptLocalManifestBeforeUploading<RecordType: MigrateableStorageServiceRecordType>(
record: RecordType,
tx: SDSAnyReadTransaction
) -> RecordType {
let necessaryMigrations = Self.necessaryMigrations(forKey: Keys.lastSuccessfulStorageServiceWrite, tx: tx)
if necessaryMigrations.isEmpty {
return record
}
guard let isPrimaryDevice = DependenciesBridge.shared.tsAccountManager.registrationState(tx: tx.asV2Read).isPrimaryDevice else {
// Not registered!
return record
}
var _builder: RecordType.Builder?
func doModifyLocalManifest<Migration: StorageServiceUnknownFieldMigration>(
migration: Migration
) {
guard RecordType.self == Migration.RecordType.self else {
return
}
let builder = _builder ?? record.asBuilder()
var typecastBuilder = builder as! Migration.RecordType.Builder
migration.interceptLocalManifest(record as! Migration.RecordType, &typecastBuilder, isPrimaryDevice, tx)
_builder = (typecastBuilder as! RecordType.Builder)
}
necessaryMigrations.forEach { migrationId in
guard let migration = migrator.migrations[migrationId] else {
return
}
doModifyLocalManifest(migration: migration)
}
if let builder = _builder {
return builder.buildInfallibly()
} else {
// It was unmodified.
return record
}
}
}
public protocol MigrateableStorageServiceRecordType {
associatedtype Builder: MigrateableStorageServiceRecordTypeBuilder where Builder.RecordType == Self
func asBuilder() -> Builder
}
public protocol MigrateableStorageServiceRecordTypeBuilder {
associatedtype RecordType: MigrateableStorageServiceRecordType
func buildInfallibly() -> RecordType
}
private protocol StorageServiceUnknownFieldMigration {
associatedtype RecordType: MigrateableStorageServiceRecordType
var mergeUnknownFields: StorageServiceUnknownFieldMigrator.Actions<RecordType>.MergeUnknownFields { get }
var interceptRemoteManifest: StorageServiceUnknownFieldMigrator.Actions<RecordType>.InterceptRemoteManifest { get }
var interceptLocalManifest: StorageServiceUnknownFieldMigrator.Actions<RecordType>.InterceptLocalManifest { get }
}
extension StorageServiceUnknownFieldMigrator {
private struct StorageServiceUnknownFieldMigrationImpl<RecordType: MigrateableStorageServiceRecordType>: StorageServiceUnknownFieldMigration {
let id: StorageServiceUnknownFieldMigrator.MigrationId
let mergeUnknownFields: Actions<RecordType>.MergeUnknownFields
let interceptRemoteManifest: Actions<RecordType>.InterceptRemoteManifest
let interceptLocalManifest: Actions<RecordType>.InterceptLocalManifest
}
}
extension StorageServiceProtoAccountRecord: MigrateableStorageServiceRecordType {}
extension StorageServiceProtoContactRecord: MigrateableStorageServiceRecordType {}
extension StorageServiceProtoGroupV1Record: MigrateableStorageServiceRecordType {}
extension StorageServiceProtoGroupV2Record: MigrateableStorageServiceRecordType {}
extension StorageServiceProtoAccountRecordBuilder: MigrateableStorageServiceRecordTypeBuilder {}
extension StorageServiceProtoContactRecordBuilder: MigrateableStorageServiceRecordTypeBuilder {}
extension StorageServiceProtoGroupV1RecordBuilder: MigrateableStorageServiceRecordTypeBuilder {}
extension StorageServiceProtoGroupV2RecordBuilder: MigrateableStorageServiceRecordTypeBuilder {}