Add batching/iterating logic for TSMessage TSAttachment migration

This commit is contained in:
Harry 2024-07-18 16:10:13 -07:00 committed by GitHub
parent cb6dcc0b9d
commit b338ff63ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -8,8 +8,295 @@ import GRDB
extension TSAttachmentMigration {
/// Migrate TSMessage TSAttachments to v2 Attachments and MessageAttachmentReferences.
///
/// The migration works in 4 phases, and can be run either incrementally (while the app is running
/// or backgrounded) or as a blocking GRDB migration.
///
/// The 4 phases must be broken up into at least 3 db transactions. (Why? Filesystem changes are
/// not part of the db transaction, so we need to first "reserve" the final file location in the db and then
/// write the file. If the latter step fails we will just rewrite to the same file location next time we retry.)
/// Phases 1/2 must be a separate transaction from phase 3, which must be different from phase 4.
///
/// Phase 1: "prepare" TSMessages for migration, starting with the newest first.
/// This will be enabled at the same time that we enable the FeatureFlag to use v2 attachments for
/// _new_ messages, so the start point marks the cutoff between legacy and v2 attachments.
/// We work backwards, newest first, to migrate the legacy attachments.
/// We "prepare" a TSMessage by inserting a row into the TSAttachmentMigration table.
///
/// Phase 2: I lied in phase 1; _some_ new messages can use legacy attachments even after the cutoff:
/// newly inserted edits on a TSMessage with un-migrated TSAttachments on it.
/// This is somewhat niche, and rare, and migrating oldest-first can result in some buggy behavior in the
/// media gallery, which is why we _mostly_ go backwards (phase 1), but then do a final cleanup by
/// going forwards from the cutoff. Since this only applies to edits made in a narrow window, we mostly
/// expect this phase to no-op and just walk over new messages finding nothing needing migrating.
///
/// Phase 3: Now that they're prepared, we walk over the TSAttachmentMigration table and migrate
/// the TSAttachments one by one. This is the bulk of the migration. We always work in newest-first order.
///
/// Phase 4: Delete all the TSAttachment folders and files on disk. Safe to do once phase 3 is complete.
///
/// When run as a blocking GRDB migration, run the phases in order in back to back, but separate, migrations.
/// This ensures they each get their own transaction, but nothing else can touch the db between them.
///
/// When run iteratively, we move back and forth between phases 1, 2, and 3.
/// We prepare batches of messages newest first (phase 1) and migrate them (phase 3) until we reach
/// the oldest TSMessage. Then we prepare batches oldest first starting at the cutoff (phase 2) and migrate
/// them (phase 3) until we reach the newest TSMessage. At that point we are done and run phase 4.
enum TSMessageMigration {
// MARK: - Phase 1/2
/// Phases 1 and 2 when applying as a blocking-on-launch GRDB migration.
static func prepareBlockingTSMessageMigration(tx: GRDBWriteTransaction) throws {
// If we finished phase 2, we are done.
let finished: Bool? = try Self.read(key: finishedGoingForwardsKey, tx: tx)
if finished == true {
return
}
guard
let maxMigratedRowId: Int64 = try Self.read(key: maxMigratedInteractionRowIdKey, tx: tx)
else {
// We've made zero progress. Migrate working backwards from the top (phase 1).
// No need for phase 2, as this will just run top to bottom.
_ = try prepareTSMessageMigrationBatch(batchSize: nil, maxRowId: nil, minRowId: nil, tx: tx)
return
}
let finishedGoingBackwards: Bool? = try Self.read(key: finishedGoingBackwardsKey, tx: tx)
if finishedGoingBackwards != true {
// We've made partial progress in phase 1, pick up where we left off working backwards.
let minMigratedRowId: Int64? = try Self.read(key: minMigratedInteractionRowIdKey, tx: tx)
_ = try prepareTSMessageMigrationBatch(
batchSize: nil,
maxRowId: minMigratedRowId ?? maxMigratedRowId,
minRowId: nil,
tx: tx
)
}
// We finished phase 1. Finish phase 2, picking up wherever we left off.
_ = try prepareTSMessageMigrationBatch(
batchSize: nil,
maxRowId: nil,
minRowId: maxMigratedRowId,
tx: tx
)
}
/// Phases 1 and 2 when running as an iterative migration.
/// - Returns
/// True if any rows were migrated; callers should keep calling until it returns false.
static func prepareNextIterativeTSMessageMigrationBatch(tx: GRDBWriteTransaction) throws -> Bool {
// If we finished phase 2, we are done.
let finished: Bool? = try Self.read(key: finishedGoingForwardsKey, tx: tx)
if finished == true {
return false
}
let batchSize = 5
guard
let maxMigratedRowId: Int64 = try Self.read(key: maxMigratedInteractionRowIdKey, tx: tx)
else {
return try Self.prepareNextIterativeBatchPhase1ColdStart(batchSize: batchSize, tx: tx)
}
// If phase 1 is done, proceed to phase 2.
let finishedGoingBackwards: Bool? = try Self.read(key: finishedGoingBackwardsKey, tx: tx)
if finishedGoingBackwards == true {
return try Self.prepareNextIteraveBatchPhase2(
batchSize: batchSize,
maxMigratedRowId: maxMigratedRowId,
tx: tx
)
}
// Otherwise continue our progress on phase 1.
return try Self.prepareNextIterativeBatchPhase1(
batchSize: batchSize,
maxMigratedRowId: maxMigratedRowId,
tx: tx
)
}
/// Cold start phase 1; start preparing messages newest-first from the top.
///
/// - Returns
/// True if any rows were migrated.
private static func prepareNextIterativeBatchPhase1ColdStart(
batchSize: Int,
tx: GRDBWriteTransaction
) throws -> Bool {
// We've made zero progress. Migrate working backwards from the top (phase 1).
guard
let maxInteractionRowId = try Int64.fetchOne(tx.database, sql: "SELECT max(id) from model_TSInteraction;")
else {
// No interactions. Must be a new install, which is fine, it means we are instantly done.
try Self.write(true, key: finishedGoingForwardsKey, tx: tx)
return false
}
// Write the cutoff point to disk.
try Self.write(maxInteractionRowId, key: maxMigratedInteractionRowIdKey, tx: tx)
// Start going backwards from the top (phase 1).
let lastMigratedRowId = try prepareTSMessageMigrationBatch(batchSize: batchSize, maxRowId: nil, minRowId: nil, tx: tx)
if let lastMigratedRowId {
// Save our incremental progress.
try Self.write(lastMigratedRowId, key: minMigratedInteractionRowIdKey, tx: tx)
return true
} else {
// If we got nothing back, there were no messages needing migrating. Finish phase 1;
// next batch we try and run will proceed to phase 2.
try Self.write(true, key: finishedGoingBackwardsKey, tx: tx)
return true
}
}
/// - Returns
/// True if any rows were migrated.
private static func prepareNextIterativeBatchPhase1(
batchSize: Int,
maxMigratedRowId: Int64,
tx: GRDBWriteTransaction
) throws -> Bool {
// Proceed going backwards from the min id, continuing our progress on phase 1.
let minMigratedRowId: Int64? = try Self.read(key: minMigratedInteractionRowIdKey, tx: tx)
let lastMigratedId = minMigratedRowId ?? maxMigratedRowId
let newMinMigratedId =
try prepareTSMessageMigrationBatch(batchSize: batchSize, maxRowId: lastMigratedId, minRowId: nil, tx: tx)
if let newMinMigratedId {
// Save our incremental progress.
try Self.write(newMinMigratedId, key: minMigratedInteractionRowIdKey, tx: tx)
return true
} else {
// If we got nothing back, there were no messages needing migrating. Finish phase 1;
// next batch we try and run will proceed to phase 2.
try Self.write(true, key: finishedGoingBackwardsKey, tx: tx)
return true
}
}
/// - Returns
/// True if any rows were migrated.
private static func prepareNextIteraveBatchPhase2(
batchSize: Int,
maxMigratedRowId: Int64,
tx: GRDBWriteTransaction
) throws -> Bool {
let newMaxMigratedId =
try prepareTSMessageMigrationBatch(batchSize: batchSize, maxRowId: nil, minRowId: maxMigratedRowId, tx: tx)
if let newMaxMigratedId {
// Save our incremental progress.
try Self.write(newMaxMigratedId, key: maxMigratedInteractionRowIdKey, tx: tx)
return true
} else {
// If we got nothing back, we are finished with phase 2.
// The value of `maxMigratedInteractionRowIdKey` will stay stale,
// but once we write `finishedGoingForwardsKey` it doesn't matter;
// we are done and none of the others get read.
try Self.write(true, key: finishedGoingForwardsKey, tx: tx)
return false
}
}
// MARK: In-progress state
private static let collectionName = "TSInteraction_TSAttachmentMigration"
// Once true, minMigratedInteractionRowIdKey should be ignored and considered stale; phase 1 is done.
private static let finishedGoingBackwardsKey = "finishedGoingBackwards"
// Once set to true, all other keys are to be ignored and considered stale; phases 1 and 2 are done.
private static let finishedGoingForwardsKey = "finishedGoingForwards"
// Marks how far we got in phase 1, as we work our way backwards (larger to smaller row ids).
private static let minMigratedInteractionRowIdKey = "minMigratedInteractionRowId"
// During phase 1, marks the cutoff where we started migrating.
// During phase 2, marks how far we got as we work our way forwards (smaller to larger row ids).
// Once phase 2 is done (finishedGoingForwardsKey = true) the value is stale and should be ignored.
private static let maxMigratedInteractionRowIdKey = "maxMigratedInteractionRowId"
private static func read<T: DatabaseValueConvertible>(key: String, tx: GRDBWriteTransaction) throws -> T? {
return try T.fetchOne(
tx.database,
sql: "SELECT value from keyvalue WHERE collection = ? AND key = ?",
arguments: [Self.collectionName, key]
)
}
private static func write<T: DatabaseValueConvertible>(_ t: T, key: String, tx: GRDBWriteTransaction) throws {
try tx.database.execute(
sql: """
INSERT INTO keyvalue (collection,key,value) VALUES (?,?,?)
ON CONFLICT(key,collection) DO UPDATE SET value = ?;
""",
arguments: [Self.collectionName, key, t, t]
)
}
// MARK: - Phase 3
/// Phase 3 when applying as a blocking-on-launch GRDB migration.
static func completeBlockingTSMessageMigration(tx: GRDBWriteTransaction) throws {
_ = try Self.completeTSMessageMigrationBatch(batchSize: nil, tx: tx)
}
/// Phase 3 when running as an iterative migration.
/// - Returns
/// True if any rows were migrated; callers should keep calling until it returns false.
static func completeNextIterativeTSMessageMigrationBatch(tx: GRDBWriteTransaction) throws -> Bool {
let batchSize = 5
let count = try Self.completeTSMessageMigrationBatch(batchSize: batchSize, tx: tx)
return count > 0
}
// MARK: - Phase 4
/// Phase 4.
/// Works the same whether its run "iteratively" or as a blocking GRDB migration.
static func cleanUpTSAttachmentFiles() throws {
// Just try and delete the folder, don't bother checking if we've tried before.
// If the folder is already deleted, this is super cheap.
let rootPath = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: TSConstants.applicationGroup
)!.path
let attachmentsFolder = rootPath.appendingPathComponent("Attachments")
guard OWSFileSystem.deleteFileIfExists(attachmentsFolder) == true else {
throw OWSAssertionError("Unable to delete folder!")
}
}
// MARK: - Migrating batches
/// Does preparation for another batch, returning the last interaction row id migrated.
///
/// If a batch size is provided, prepares only that many messages. Otherwise prepares them all.
///
/// If maxRowId is provided, prepares messages in descending order by row id starting with the provided id (non-inclusive).
/// If minRowId is provided, prepares messages in ascending order by row id starting with the provided id (non-inclusive).
/// If neither is provided, prepares messages in descending order by row id starting with the latest message (inclusive).
private static func prepareTSMessageMigrationBatch(
batchSize: Int?,
maxRowId: Int64?,
minRowId: Int64?,
tx: GRDBWriteTransaction
) throws -> Int64? {
fatalError("TODO")
}
/// Completes another prepared batch, returns count of touched message rows.
///
/// If a batch size is provided, prepares only that many prepared messages. Otherwise migares all prepared messages.
private static func completeTSMessageMigrationBatch(
batchSize: Int?,
tx: GRDBWriteTransaction
) throws -> Int {
fatalError("TODO")
}
// MARK: - NSKeyedArchiver/Unarchiver
private static func unarchive<T: NSCoding>(_ data: Data) throws -> T {
let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
unarchiver.requiresSecureCoding = false