Skip to content

Commit

Permalink
Merge pull request #1224 from planetary-social/create-database-cleaner
Browse files Browse the repository at this point in the history
Move database cleanup routine into DatabaseCleaner type
  • Loading branch information
mplorentz authored Jun 10, 2024
2 parents 7af2bcc + 841f492 commit 6c0fbdf
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 120 deletions.
10 changes: 10 additions & 0 deletions Nos.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,9 @@
C9BCF1C12AC72020009BDE06 /* UNSWizardChooseNameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9BCF1C02AC72020009BDE06 /* UNSWizardChooseNameView.swift */; };
C9BD91892B61BBEF00FDA083 /* bad_contact_list.json in Resources */ = {isa = PBXBuildFile; fileRef = C9BD91882B61BBEF00FDA083 /* bad_contact_list.json */; };
C9BD919B2B61C4FB00FDA083 /* RawNostrID+Random.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9BD919A2B61C4FB00FDA083 /* RawNostrID+Random.swift */; };
C9C097232C13534800F78EC3 /* DatabaseCleanerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C097222C13534800F78EC3 /* DatabaseCleanerTests.swift */; };
C9C097252C13537900F78EC3 /* DatabaseCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C097242C13537900F78EC3 /* DatabaseCleaner.swift */; };
C9C097262C13537900F78EC3 /* DatabaseCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C097242C13537900F78EC3 /* DatabaseCleaner.swift */; };
C9C2B77C29E072E400548B4A /* WebSocket+Nos.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C2B77B29E072E400548B4A /* WebSocket+Nos.swift */; };
C9C2B77D29E072E400548B4A /* WebSocket+Nos.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C2B77B29E072E400548B4A /* WebSocket+Nos.swift */; };
C9C2B77F29E0731600548B4A /* AsyncTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C2B77E29E0731600548B4A /* AsyncTimer.swift */; };
Expand Down Expand Up @@ -756,6 +759,8 @@
C9BCF1C02AC72020009BDE06 /* UNSWizardChooseNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UNSWizardChooseNameView.swift; sourceTree = "<group>"; };
C9BD91882B61BBEF00FDA083 /* bad_contact_list.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = bad_contact_list.json; sourceTree = "<group>"; };
C9BD919A2B61C4FB00FDA083 /* RawNostrID+Random.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RawNostrID+Random.swift"; sourceTree = "<group>"; };
C9C097222C13534800F78EC3 /* DatabaseCleanerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseCleanerTests.swift; sourceTree = "<group>"; };
C9C097242C13537900F78EC3 /* DatabaseCleaner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseCleaner.swift; sourceTree = "<group>"; };
C9C2B77B29E072E400548B4A /* WebSocket+Nos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebSocket+Nos.swift"; sourceTree = "<group>"; };
C9C2B77E29E0731600548B4A /* AsyncTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncTimer.swift; sourceTree = "<group>"; };
C9C2B78129E0735400548B4A /* RelaySubscriptionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySubscriptionManager.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -963,6 +968,7 @@
035729B72BE416A6005FEE85 /* Service */ = {
isa = PBXGroup;
children = (
C9C097222C13534800F78EC3 /* DatabaseCleanerTests.swift */,
035729B42BE416A6005FEE85 /* DirectMessageWrapperTests.swift */,
035729B52BE416A6005FEE85 /* GiftWrapperTests.swift */,
037975D02C0E341500ADDF37 /* MockFeatureFlags.swift */,
Expand Down Expand Up @@ -1176,6 +1182,7 @@
C9A0DAF729C92F4500466635 /* UNSAPI.swift */,
032634512C10BB8F00E489B5 /* FileStorage */,
C98CA9052B14FA8500929141 /* Relay */,
C9C097242C13537900F78EC3 /* DatabaseCleaner.swift */,
);
path = Service;
sourceTree = "<group>";
Expand Down Expand Up @@ -2014,6 +2021,7 @@
C960C57129F3236200929990 /* LikeButton.swift in Sources */,
C97797B9298AA19A0046BD25 /* RelayService.swift in Sources */,
C99721CB2AEBED26004EBEAB /* String+Empty.swift in Sources */,
C9C097252C13537900F78EC3 /* DatabaseCleaner.swift in Sources */,
C959DB762BD01DF4008F3627 /* GiftWrapper.swift in Sources */,
5B29B5842BEAA0D7008F6008 /* BioSheet.swift in Sources */,
C93CA0C329AE3A1E00921183 /* JSONEvent.swift in Sources */,
Expand Down Expand Up @@ -2134,6 +2142,7 @@
A3B943D7299D6DB700A15A08 /* Follow+CoreDataClass.swift in Sources */,
C9ADB13E29929EEF0075E7F8 /* Bech32.swift in Sources */,
5B098DC92BDAF7CF00500A1B /* NoteParserTests+NIP27.swift in Sources */,
C9C097262C13537900F78EC3 /* DatabaseCleaner.swift in Sources */,
C9DEC05A2989509B0078B43A /* PersistenceController.swift in Sources */,
C9C2B78629E073E300548B4A /* RelaySubscription.swift in Sources */,
C973AB662A323167002AED16 /* EventReference+CoreDataProperties.swift in Sources */,
Expand Down Expand Up @@ -2162,6 +2171,7 @@
0326347B2C10C57A00E489B5 /* FileStorageAPIClient.swift in Sources */,
C9B678DC29EEBF3B00303F33 /* DependencyInjection.swift in Sources */,
C9F0BB6D29A503D9000547FC /* Int+Bool.swift in Sources */,
C9C097232C13534800F78EC3 /* DatabaseCleanerTests.swift in Sources */,
DC08FF812A7969C5009F87D1 /* UIDevice+Simulator.swift in Sources */,
5BFBB2962BD9D824002E909F /* URLParser.swift in Sources */,
C959DB772BD01DF4008F3627 /* GiftWrapper.swift in Sources */,
Expand Down
132 changes: 13 additions & 119 deletions Nos/Controller/PersistenceController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class PersistenceController {

@Dependency(\.currentUser) var currentUser
@Dependency(\.analytics) var analytics
@Dependency(\.crashReporting) var crashReporting

/// Increment this to delete core data on update
static let version = 3
Expand Down Expand Up @@ -186,130 +187,23 @@ class PersistenceController {
UserDefaults.standard.set(newVersion, forKey: Self.versionKey)
}

var cleanupTask: Task<Void, Error>?

// swiftlint:disable function_body_length

/// Deletes unneeded entities from Core Data.
/// The general strategy here is to:
/// - keep some max number of events, delete the others
/// - delete authors outside the user's network
/// - delete any other models that are orphaned by the previous deletions
/// - fix EventReferences whose referencedEvent was deleted by createing a stubbed Event
@MainActor func cleanupEntities() {
// this function was written in a hurry and probably should be refactored and tested thorougly.
guard cleanupTask == nil else {
Log.info("Core Data cleanup task already running. Aborting.")
/// Cleans up uneeded entities from the database. Our local database is really just a cache, and we need to
/// invalidate old items to keep it from growing indefinitely.
///
/// This should only be called once right at app launch.
func cleanupEntities() async {
guard let authorKey = await currentUser.author?.hexadecimalPublicKey else {
return
}

guard let authorKey = currentUser.author?.hexadecimalPublicKey else {
return
}

cleanupTask = Task {
defer { self.cleanupTask = nil }
let context = newBackgroundContext()
let startTime = Date.now
Log.info("Starting Core Data cleanup...")

Log.info("Database statistics: \(try await Self.databaseStatistics(from: context))")

// Delete all but the most recent n events
let eventsToKeep = 1000
let fetchFirstEventToDelete = Event.allEventsRequest()
fetchFirstEventToDelete.sortDescriptors = [NSSortDescriptor(keyPath: \Event.receivedAt, ascending: false)]
fetchFirstEventToDelete.fetchLimit = 1
fetchFirstEventToDelete.fetchOffset = eventsToKeep
fetchFirstEventToDelete.predicate = NSPredicate(format: "receivedAt != nil")
var deleteBefore = Date.distantPast
try await context.perform {

guard let currentAuthor = try? Author.find(by: authorKey, context: context) else {
return
}

if let firstEventToDelete = try context.fetch(fetchFirstEventToDelete).first,
let receivedAt = firstEventToDelete.receivedAt {
deleteBefore = receivedAt
}

let oldStoryCutoff = Calendar.current.date(byAdding: .day, value: -2, to: .now) ?? .now

// Delete events older than `deleteBefore`
let oldEventsRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Event")
oldEventsRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.receivedAt, ascending: true)]
let oldEventClause = "(receivedAt <= %@ OR receivedAt == nil)"
let notOwnEventClause = "(author.hexadecimalPublicKey != %@)"
let readStoryClause = "(isRead = 1 AND receivedAt > %@)"
let userReportClause = "(kind == \(EventKind.report.rawValue) AND " +
"authorReferences.@count > 0 AND eventReferences.@count == 0)"
let clauses = "\(oldEventClause) AND" +
"\(notOwnEventClause) AND " +
"NOT \(readStoryClause) AND " +
"NOT \(userReportClause)"
oldEventsRequest.predicate = NSPredicate(
format: clauses,
deleteBefore as CVarArg,
authorKey,
oldStoryCutoff as CVarArg
)

let deleteRequests: [NSPersistentStoreRequest] = [
oldEventsRequest,
Event.expiredRequest(),
EventReference.orphanedRequest(),
AuthorReference.orphanedRequest(),
Author.outOfNetwork(for: currentAuthor),
Follow.orphanedRequest(),
Relay.orphanedRequest(),
NosNotification.oldNotificationsRequest(),
]

for request in deleteRequests {
guard let fetchRequest = request as? NSFetchRequest<any NSFetchRequestResult> else {
Log.error("Bad fetch request: \(request)")
continue
}

let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batchDeleteRequest.resultType = .resultTypeCount
let deleteResult = try context.execute(batchDeleteRequest) as? NSBatchDeleteResult
if let entityName = fetchRequest.entityName {
Log.info("Deleted \(deleteResult?.result ?? 0) of type \(entityName)")
}
}

// Heal EventReferences
let brokenEventReferencesRequest = NSFetchRequest<EventReference>(entityName: "EventReference")
brokenEventReferencesRequest.sortDescriptors = [
NSSortDescriptor(keyPath: \EventReference.eventId, ascending: false)
]
brokenEventReferencesRequest.predicate = NSPredicate(format: "referencedEvent = nil")
let brokenEventReferences = try context.fetch(brokenEventReferencesRequest)
Log.info("Healing \(brokenEventReferences.count) EventReferences")
for eventReference in brokenEventReferences {
guard let eventID = eventReference.eventId else {
Log.error("Found an EventReference with no eventID")
continue
}
let referencedEvent = try Event.findOrCreateStubBy(id: eventID, context: context)
eventReference.referencedEvent = referencedEvent
}

try context.saveIfNeeded()
context.refreshAllObjects()
}

let newStatistics = try await Self.databaseStatistics(from: context)
Log.info("Database statistics: \(newStatistics)")
analytics.databaseStatistics(newStatistics)

let elapsedTime = Date.now.timeIntervalSince1970 - startTime.timeIntervalSince1970
Log.info("Finished Core Data cleanup in \(elapsedTime) seconds.")
let context = newBackgroundContext()
do {
try await DatabaseCleaner.cleanupEntities(before: Date.now, for: authorKey, in: context)
} catch {
Log.optional(error)
crashReporting.report("Error in database cleanup: \(error.localizedDescription)")
}
}
// swiftlint:enable function_body_length

static func databaseStatistics(from context: NSManagedObjectContext) async throws -> [(String, Int)] {
try await context.perform {
Expand Down
2 changes: 1 addition & 1 deletion Nos/NosApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ struct NosApp: App {
fileStorageAPIClient.refreshServerInfo()
}
.task {
persistenceController.cleanupEntities()
await persistenceController.cleanupEntities()
}
.onChange(of: scenePhase) { _, newPhase in
// TODO: save all contexts, not just the view and background.
Expand Down
126 changes: 126 additions & 0 deletions Nos/Service/DatabaseCleaner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import Foundation
import Logger
import CoreData
import Dependencies

enum DatabaseCleaner {

// swiftlint:disable function_body_length

/// Deletes unneeded entities from Core Data.
///
/// This should only be called once right at app launch.
///
/// The general strategy here is to:
/// - keep some max number of events, delete the others
/// - delete authors outside the user's network
/// - delete any other models that are orphaned by the previous deletions
/// - fix EventReferences whose referencedEvent was deleted by createing a stubbed Event
static func cleanupEntities(
before date: Date,
for authorKey: RawAuthorID,
in context: NSManagedObjectContext
) async throws {
// this function was written in a hurry and probably should be refactored and tested thorougly.
@Dependency(\.analytics) var analytics

let startTime = Date.now
Log.info("Starting Core Data cleanup...")

Log.info("Database statistics: \(try await PersistenceController.databaseStatistics(from: context))")

// Delete all but the most recent n events
let eventsToKeep = 1000
let fetchFirstEventToDelete = Event.allEventsRequest()
fetchFirstEventToDelete.sortDescriptors = [NSSortDescriptor(keyPath: \Event.receivedAt, ascending: false)]
fetchFirstEventToDelete.fetchLimit = 1
fetchFirstEventToDelete.fetchOffset = eventsToKeep
fetchFirstEventToDelete.predicate = NSPredicate(format: "receivedAt != nil")
var deleteBefore = Date.distantPast
try await context.perform {

guard let currentAuthor = try? Author.find(by: authorKey, context: context) else {
return
}

if let firstEventToDelete = try context.fetch(fetchFirstEventToDelete).first,
let receivedAt = firstEventToDelete.receivedAt {
deleteBefore = receivedAt
}

let oldStoryCutoff = Calendar.current.date(byAdding: .day, value: -2, to: .now) ?? .now

// Delete events older than `deleteBefore`
let oldEventsRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Event")
oldEventsRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.receivedAt, ascending: true)]
let oldEventClause = "(receivedAt <= %@ OR receivedAt == nil)"
let notOwnEventClause = "(author.hexadecimalPublicKey != %@)"
let readStoryClause = "(isRead = 1 AND receivedAt > %@)"
let userReportClause = "(kind == \(EventKind.report.rawValue) AND " +
"authorReferences.@count > 0 AND eventReferences.@count == 0)"
let clauses = "\(oldEventClause) AND" +
"\(notOwnEventClause) AND " +
"NOT \(readStoryClause) AND " +
"NOT \(userReportClause)"
oldEventsRequest.predicate = NSPredicate(
format: clauses,
deleteBefore as CVarArg,
authorKey,
oldStoryCutoff as CVarArg
)

let deleteRequests: [NSPersistentStoreRequest] = [
oldEventsRequest,
Event.expiredRequest(),
EventReference.orphanedRequest(),
AuthorReference.orphanedRequest(),
Author.outOfNetwork(for: currentAuthor),
Follow.orphanedRequest(),
Relay.orphanedRequest(),
NosNotification.oldNotificationsRequest(),
]

for request in deleteRequests {
guard let fetchRequest = request as? NSFetchRequest<any NSFetchRequestResult> else {
Log.error("Bad fetch request: \(request)")
continue
}

let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batchDeleteRequest.resultType = .resultTypeCount
let deleteResult = try context.execute(batchDeleteRequest) as? NSBatchDeleteResult
if let entityName = fetchRequest.entityName {
Log.info("Deleted \(deleteResult?.result ?? 0) of type \(entityName)")
}
}

// Heal EventReferences
let brokenEventReferencesRequest = NSFetchRequest<EventReference>(entityName: "EventReference")
brokenEventReferencesRequest.sortDescriptors = [
NSSortDescriptor(keyPath: \EventReference.eventId, ascending: false)
]
brokenEventReferencesRequest.predicate = NSPredicate(format: "referencedEvent = nil")
let brokenEventReferences = try context.fetch(brokenEventReferencesRequest)
Log.info("Healing \(brokenEventReferences.count) EventReferences")
for eventReference in brokenEventReferences {
guard let eventID = eventReference.eventId else {
Log.error("Found an EventReference with no eventID")
continue
}
let referencedEvent = try Event.findOrCreateStubBy(id: eventID, context: context)
eventReference.referencedEvent = referencedEvent
}

try context.saveIfNeeded()
context.refreshAllObjects()
}

let newStatistics = try await PersistenceController.databaseStatistics(from: context)
Log.info("Database statistics: \(newStatistics)")
analytics.databaseStatistics(newStatistics)

let elapsedTime = Date.now.timeIntervalSince1970 - startTime.timeIntervalSince1970
Log.info("Finished Core Data cleanup in \(elapsedTime) seconds.")
}
// swiftlint:enable function_body_length
}
20 changes: 20 additions & 0 deletions NosTests/Service/DatabaseCleanerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import XCTest
import CoreData

final class DatabaseCleanerTests: CoreDataTestCase {

func test_emptyDatabase() async throws {
// Act
try await DatabaseCleaner.cleanupEntities(before: Date.now, for: KeyFixture.alice.publicKeyHex, in: testContext)

// Assert that the database is still empty
let managedObjectModel = try XCTUnwrap(testContext.persistentStoreCoordinator?.managedObjectModel)
let entitiesByName = managedObjectModel.entitiesByName
XCTAssertGreaterThan(entitiesByName.count, 0) // sanity check

for entityName in entitiesByName.keys {
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
XCTAssertEqual(try testContext.count(for: fetchRequest), 0)
}
}
}

0 comments on commit 6c0fbdf

Please sign in to comment.