swiftuiconcurrencyswiftdataswift-concurrency

My Swift concurrency is no longer running properly and it's screwing up my SwiftData objects


I have a bug I've come across since I've upgraded Xcode (16.0) and macOS (15.0 Sequoia) and figured I'd create a minimal viable example in case someone else came across this and help. I've noticed this in both the macOS and iOS version of my app when I run it.

Essentially I have an area of my code that calls a class that has a step 1 and 2 process. The first step uses async let _ = await methodCall(...) to create some SwiftData items while the second step uses a TaskGroup to go through the created elements, checks if an image is needed, and syncs it. Before this worked great as the first part finished and saved before the second part happened. Now it doesn't see the save and thus doesn't see the insertions/edits/etc in the first part so the second part just isn't done properly. Those step one changes are set and shown in the UI so, in this case, if I run it again the second part works just fine on those previous items while any newly created items are skipped.

I came across this issue when step one handled 74 inserts as each one didn't contain an image attached to it. When I switched the async let _ = await methodCall(...) to a TaskGroup, hoping that would wait better and work properly, I had the same issue but now only 10 to 30 items were created from the first step.

Minimal Viable Example: to reproduce something similar

In my minimal viable sample I simplified it way down so you can't run it twice and it's limited it creating 15 subitems. That said, I've hooked it up with both an async let _ = await methodCall(...) dubbed AL and a TaskGroup dubbed TG. With both types my second process (incrementing the number associated with the subissue/subitem) isn't run as it doesn't see the subitem as having been created and, when run with TaskGroup, only 12 to 15 items are created rather that the always 15 of async let. Here's my code:

import SwiftUI
import SwiftData
import Foundation

// MARK: Model with main `Issue` and `sub issues`.
@Model
class Issue {
  var name: String
  // Relatioship
  @Relationship(deleteRule: .cascade, inverse: \Subissue.issue) var subissues: [Subissue]
  init(name: String, subIssues: [Subissue] = [Subissue]()) {
    self.name = name
    self.subissues = subIssues
  }
}
@Model
class Subissue {
  var name: String
  var numberEdited: Int = 0
  // Relationship
  var issue: Issue?
  init(name: String, issue: Issue) {
    self.name = name
    self.numberEdited = 0
    self.issue = issue
  }
}

// MARK: Main entrance
@main
struct TestMultiplatformApp: App {
  var sharedModelContainer: ModelContainer = {
    let schema = Schema([
      Issue.self,
    ])
    let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
    do {
      return try ModelContainer(for: schema, configurations: [modelConfiguration])
    } catch {
      fatalError("Could not create ModelContainer: \(error)")
    }
  }()
  @State var refreshViewID: UUID = UUID()
  var body: some Scene {
    WindowGroup {
      ContentView(refreshViewID: $refreshViewID)
        .id(refreshViewID)
    }
    .modelContainer(sharedModelContainer)
  }
}


// MARK: Class with the async methods that are called
final class BackgroundClass: Sendable {
  public func asyncTaskGroup(in container: ModelContainer) async -> (error: String?, issueID: PersistentIdentifier?) {
    let modelContext = ModelContext(container)
    modelContext.autosaveEnabled = false
    // Create new issue
    let issue = Issue(name: "TG - Issue: \(Date.now.timeIntervalSince1970)")
    modelContext.insert(issue)
    do {
      try modelContext.save()
    } catch {
      return (error: "Unable to save. Error: \(error.localizedDescription)", issueID: nil)
    }
    print("Main TG: Before creating the count is \(issue.subissues.count)")
    let _ = await withTaskGroup(of: Void.self, body: { taskGroup in
      let modelContainer = issue.modelContext!.container
      let newID = issue.persistentModelID
      print("TG create: Before creating the count is \(issue.subissues.count)")
      for i in 1...15 {
        // Add child task to task group
        taskGroup.addTask {
          let _ = await self.createSubIssue(in: modelContainer, issueID: newID, name: i)
          // Return child task result
          return
        }
      }
      print("TG create: After creating the count is \(issue.subissues.count)")
    })
    print("Main TG: Between the count is \(issue.subissues.count)")
    let errorReturned = await doSecondaryProcess(issue: issue)
    if let error = errorReturned, error.count > 0 {
      return (error: error, issueID: nil)
    }
    print("Main TG: After processing the count is \(issue.subissues.count)")
    return (error: nil, issueID: issue.persistentModelID)
  }
  public func asyncLetTest(in container: ModelContainer) async -> (error: String?, issueID: PersistentIdentifier?) {
    let modelContext = ModelContext(container)
    modelContext.autosaveEnabled = false
    // Create new issue
    let issue = Issue(name: "AL - Issue: \(Date.now.timeIntervalSince1970)")
    modelContext.insert(issue)
    do {
      try modelContext.save()
    } catch {
      return (error: "Unable to save. Error: \(error.localizedDescription)", issueID: nil)
    }
    print("Main AL: Before creating the count is \(issue.subissues.count)")
    let _ = await createSubissues(issue: issue)
    print("Main AL: Between the count is \(issue.subissues.count)")
    let errorReturned = await doSecondaryProcess(issue: issue)
    if let error = errorReturned, error.count > 0 {
      return (error: error, issueID: nil)
    }
    print("Main AL: After processing the count is \(issue.subissues.count)")
    return (error: nil, issueID: issue.persistentModelID)
  }
  // MARK: First set of items
  internal final func createSubissues(issue: Issue) async {
    let modelContainer = issue.modelContext!.container
    let thisID = issue.persistentModelID
    print("create: Before creating the count is \(issue.subissues.count)")
    for i in 1...15 {
      async let _ = createSubIssue(in: modelContainer, issueID: thisID, name: i)
    }
    print("create: After creating the count is \(issue.subissues.count)")
  }
  internal final func createSubIssue(in container: ModelContainer, issueID: PersistentIdentifier, name: Int) async -> String? {
    let modelContext = ModelContext(container)
    modelContext.autosaveEnabled = false
    let thisIssue = modelContext.model(for: issueID) as? Issue
    guard let thisIssue else { return "Unable to proceed as we're unable to find this issue." }
    let newSubIssue = Subissue(name: "Subissue \(name) - \(Date.now.timeIntervalSince1970)", issue: thisIssue)
    do {
      try modelContext.save()
    } catch {
      return "Unable to save new subissue \(newSubIssue.name). Error: \(error)"
    }
    print("Created subissue: \(thisIssue.subissues.count) - \(newSubIssue.name)")
    return nil
  }
  // MARK: Secondary set of processing after creation
  internal final func doSecondaryProcess(issue: Issue) async -> String? {
    let taskGroupResults = await withTaskGroup(
      of: (myError: [String], myText: [String]).self,
       returning: (myTexts: [String], myErrors: [String]).self,
       body: { taskGroup in
         // Loop through the issue's subissues. Which as above isn't saved is none
         print("Going through increment. Have \(issue.subissues.count) subissue(s).")
         for mySubissue in (issue.subissues) {
           let modelContainer = mySubissue.modelContext!.container
           let thisID = mySubissue.persistentModelID
           // Add child task to task group
           taskGroup.addTask {
             // Execute slow operation
             let value = await self.incrementNumberSlowly(in: modelContainer, myID: thisID)
          // Return child task result -> same as 'of' above
             return (myError: value.myError == nil ? [] : [value.myError!], myText: value.myText == nil ? [] : [value.myText!])
        }
      }
      var errors = [String]()
      var myTexts = [String]()
      // Collect results of all child task in a dictionary
      for await result in taskGroup {
        // append to other arrays
        errors = errors+result.myError
        myTexts = myTexts+result.myText
      }
      // Task group finish running & return task group result
      return (myTexts: myTexts, myErrors: errors) // same value as allResults and returning above.
    })
    print("After calling background class values returned for issue count of \(issue.subissues.count) is \(taskGroupResults.myTexts.count) and \(taskGroupResults.myErrors.count)")
    if taskGroupResults.myErrors.count > 0 {
      print("My Texts")
      print("\t* \(taskGroupResults.myTexts.joined(separator: "\n\t* "))")
    }
    if taskGroupResults.myErrors.count > 0 {
      print("My Errors")
      print("\t* \(taskGroupResults.myErrors.joined(separator: "\n\t* "))")
    }
    return nil
  }
  private func incrementNumberSlowly(in container: ModelContainer, myID: PersistentIdentifier) async -> (myError: String?, myText: String?) {
    let modelContext = ModelContext(container)
    modelContext.autosaveEnabled = false
    let mySubIssue = modelContext.model(for: myID) as? Subissue
    guard let mySubIssue else {
      return (myError: "Unable to find subissue.", myText: nil)
    }
    print("In increment before: \(mySubIssue.numberEdited)")
    mySubIssue.numberEdited = mySubIssue.numberEdited + 1
    print("In increment after: \(mySubIssue.numberEdited)")
    return (myError: nil, myText: "Number incremented to \(mySubIssue.numberEdited)")
  }
}

// MARK: Content view showing the information and calling the above methods
struct ContentView: View {
  @Environment(\.modelContext) var modelContext
  @Query var allIssues: [Issue]
  @State var selectedIssue: Issue?
  @Query var allSubissues: [Subissue]
  @Binding var refreshViewID: UUID
  var body: some View {
    VStack {
      Section("Data", content: {
        HStack {
          Spacer()
          VStack {
            Text("Issue Count: \(allIssues.count)")
            Text("SubIssue Count: \(allSubissues.count)")
          }
          Spacer()
          Button(action: {
            refreshViewID = UUID()
          }, label: {
            Text("Refresh View")
          })
          Spacer()
        }
      })
      Section("Actions", content: {
        HStack {
          Spacer()
          Button(action: {
            Task {
              let result = await BackgroundClass().asyncLetTest(in: modelContext.container)
              print("Result: \(result)")
            }
          }, label: {
            Text("Run Async Let")
          })
          Spacer()
          Button(action: {
            Task {
              let result = await BackgroundClass().asyncTaskGroup(in: modelContext.container)
              print("Result: \(result)")
            }
          }, label: {
            Text("Run Task Group")
          })
          Spacer()
          Button(action: {
            for issue in allIssues {
              for subissue in issue.subissues {
                modelContext.delete(subissue)
              }
              modelContext.delete(issue)
            }
            for subissue in allSubissues {
              modelContext.delete(subissue)
            }
            // Saving breaks this: Thread 1: Fatal error: Context is missing for Optional(SwiftData.PersistentIdentifier(id: SwiftData.PersistentIdentifier.ID(url: x-coredata://5BA1C592-F9BF-45A0-9ED5-751B0A8AC593/Issue/p10), implementation: SwiftData.PersistentIdentifierImplementation))
//            do {
//              try modelContext.save()
//            } catch {
//              print("Model Context not saved! Error: \(error.localizedDescription)")
//            }
          }, label: {
            Text("Delete All")
          })
          Spacer()
        }
      })
#if os(macOS)
      Divider()
#endif
      HStack {
        List(selection: $selectedIssue, content: {
          ForEach(allIssues) { issue in
            Button(action: {
              selectedIssue = issue
            }) {
              HStack {
                Spacer()
                Text(issue.name).bold()
                Spacer()
                Text("Subissues: \(issue.subissues.count)")
                Spacer()
              }
            }
          }
        })
        if let selectedIssue {
          List(selectedIssue.subissues) { subIssue in
            HStack {
              Spacer()
              Text(subIssue.name).bold()
              Spacer()
              if subIssue.numberEdited > 0 {
                Text("Edited: \(subIssue.numberEdited)").bold()
              } else {
                Text("Edited: \(subIssue.numberEdited)").tint(.secondary)
              }
              Spacer()
            }
          }
        } else {
          Text("Please select an issue to see the subissues involved.")
            .multilineTextAlignment(.center)
        }
      }
    }
  }
}

And this gives a macOS view of showing each time the button is pressed the sub issues created never increment to 1 while, when using TaskGroup, 15 isn't guaranteed to be created and remembered.

View of the macOS platform showing the sub issues not being constant with task group and no increment as all ints are set to 0.

I'm essentially wondering if anyone else has this issue and if so have you figured out how to solve it? Thanks

UPDATE

Thank you so much for the help. In case people come across this in the future I will share the updated example from above. Here I added an actor class (in my actual code used it in a environmentObject so it's only created once) and updated the background class code. Here it is:


import SwiftData
import Foundation

@ModelActor public actor ExampleModelActor {
    func incrementIssueNumbersSlowly(issueID: PersistentIdentifier) async -> String? {
        let myIssue = modelContext.model(for: issueID) as? Issue
        guard let myIssue else {
           return "Unable to find issue."
        }
        let taskGroupResults = await withTaskGroup(
            of: (myError: [String], myText: [String]).self,
             returning: (myTexts: [String], myErrors: [String]).self,
             body: { taskGroup in
                 // Loop through the issue's subissues. Which as above isn't saved is none
                 print("Going through increment. Have \(myIssue.subissues.count) subissue(s).")
                 for mySubissue in (myIssue.subissues) {
                     let thisID = mySubissue.persistentModelID
                     // Add child task to task group
                     taskGroup.addTask {
                         // Execute slow  operation
                         let value = await self.incrementNumberSlowly(myID: thisID)
//                         let value = await self.incrementNumberSlowly(in: modelContainer, myID: thisID)
                    
                    // Return child task result ->  same as 'of' above
                         return (myError: value.myError == nil ? [] : [value.myError!], myText: value.myText == nil ? [] : [value.myText!])
                }
                    
            }
            
            var errors = [String]()
            var myTexts = [String]()
            
            // Collect results of all child task in a dictionary
            for await result in taskGroup {
                // append to other arrays
                errors = errors+result.myError
                myTexts = myTexts+result.myText
            }
            
            // Task group finish running & return task group result
            return (myTexts: myTexts, myErrors: errors) // same value as allResults and returning above.
        })
        
        print("After calling background class values returned for issue count of \(myIssue.subissues.count) is \(taskGroupResults.myTexts.count) and \(taskGroupResults.myErrors.count)")
        if taskGroupResults.myErrors.count > 0 {
            print("My Texts")
            print("\t* \(taskGroupResults.myTexts.joined(separator: "\n\t* "))")
        }
        if taskGroupResults.myErrors.count > 0 {
            print("My Errors")
            print("\t* \(taskGroupResults.myErrors.joined(separator: "\n\t* "))")
        }
        return nil
    }
    
    func incrementNumberSlowly(myID: PersistentIdentifier) async -> (myError: String?, myText: String?) {
        let mySubIssue = modelContext.model(for: myID) as? Subissue
        guard let mySubIssue else {
           return (myError: "Unable to find subissue.", myText: nil)
        }
        print("In increment before: \(mySubIssue.numberEdited)")
        mySubIssue.numberEdited = mySubIssue.numberEdited + 1
        print("In increment after: \(mySubIssue.numberEdited)")
        
        do {
            try modelContext.save()
        } catch { 
            return (myError: "Unable to increment the number. Error: \(error)", myText: nil)
        }
        return (myError: nil, myText: "Number incremented to \(mySubIssue.numberEdited)")
    }
    
    func createSubIssue(issueID: PersistentIdentifier, name: Int) async -> String? {
        let thisIssue = modelContext.model(for: issueID) as? Issue
        guard let thisIssue else { return "Unable to proceed as we're unable to find this issue." }
        
        let newSubIssue = Subissue(name: "Subissue \(name) - \(Date.now.timeIntervalSince1970)", issue: thisIssue)
        
        do {
            try modelContext.save()
        } catch {
            return "Unable to save new subissue \(newSubIssue.name). Error: \(error)"
        }
        print("Created subissue: \(thisIssue.subissues.count) - \(newSubIssue.name)")
        return nil
    }
}

And updated BackgroundClass:


import SwiftData
import Foundation
import os.log

final class BackgroundClass: Sendable {
    let maxNumber = 100
    let poi = OSSignposter(subsystem: "BackgroundClass", category: .pointsOfInterest)

    public func asyncTaskGroup(in container: ModelContainer) async -> (error: String?, issueID: PersistentIdentifier?) {
        let state = poi.beginInterval(#function, id: poi.makeSignpostID())
               defer { poi.endInterval(#function, state) }

              
        let modelContext = ModelContext(container)
        modelContext.autosaveEnabled = false
        
        // Create new issue
        let issue = Issue(name: "TG - Issue: \(Date.now.timeIntervalSince1970)")
        modelContext.insert(issue)
        do {
            try modelContext.save()
        } catch {
            return (error: "Unable to save. Error: \(error.localizedDescription)", issueID: nil)
        }
        
//        print("Main TG: Before creating the count is \(issue.subissues.count)")
        poi.emitEvent(#function, "Main TG: Before creating the count is \(issue.subissues.count)")

        let _ = await withTaskGroup(of: Void.self, body: { taskGroup in
            let actor = ExampleModelActor(modelContainer: issue.modelContext!.container)
            let newID = issue.persistentModelID
            
            print("TG create: Before creating the count is \(issue.subissues.count)")
            for i in 1...maxNumber {
                // Add child task to task group
                taskGroup.addTask {
                    async let _ = actor.createSubIssue(issueID: newID, name: i)
//                    let _ = await self.createSubIssue(in: modelContainer, issueID: newID, name: i)
                    // Return child task result
                    return
                }
            }
            print("TG create: After creating the count is \(issue.subissues.count)")
            
        })
        
        poi.emitEvent(#function, "Main TG: Between the count is \(issue.subissues.count)")
        
        
        let actor = ExampleModelActor(modelContainer: issue.modelContext!.container)
        let errorReturned = await actor.incrementIssueNumbersSlowly(issueID: issue.persistentModelID)
        if let error = errorReturned, error.count > 0 {
            return (error: error, issueID: nil)
        }
        poi.emitEvent(#function, "Main TG: After processing the count is \(issue.subissues.count)")
        
        return (error: nil, issueID: issue.persistentModelID)
        
    }
    
    public func asyncLetTest(in container: ModelContainer) async -> (error: String?, issueID: PersistentIdentifier?) {
        let state = poi.beginInterval(#function, id: poi.makeSignpostID())
        defer { poi.endInterval(#function, state) }
        let modelContext = ModelContext(container)
        modelContext.autosaveEnabled = false
        
        // Create new issue
        let issue = Issue(name: "AL - Issue: \(Date.now.timeIntervalSince1970)")
        modelContext.insert(issue)
        do {
            try modelContext.save()
        } catch {
            return (error: "Unable to save. Error: \(error.localizedDescription)", issueID: nil)
        }
        
        poi.emitEvent(#function, "Main AL: Before creating the count is \(issue.subissues.count)")
        
        let _ = await createSubissues(issue: issue)
        
        poi.emitEvent(#function, "Main AL: Between the count is \(issue.subissues.count)")
        
        let actor = ExampleModelActor(modelContainer: issue.modelContext!.container)
        let errorReturned = await actor.incrementIssueNumbersSlowly(issueID: issue.persistentModelID)
        if let error = errorReturned, error.count > 0 {
            return (error: error, issueID: nil)
        }
        poi.emitEvent(#function, "Main AL: After processing the count is \(issue.subissues.count)")
        
        return (error: nil, issueID: issue.persistentModelID)
    }
    
    // MARK: First set of items
    internal final func createSubissues(issue: Issue) async {
        let actor = ExampleModelActor(modelContainer: issue.modelContext!.container)
//        let modelContainer = issue.modelContext!.container
        let thisID = issue.persistentModelID
        
        print("create: Before creating the count is \(issue.subissues.count)")
        for i in 1...maxNumber {
            async let _ = actor.createSubIssue(issueID: thisID, name: i)
        }
        print("create: After creating the count is \(issue.subissues.count)")
    }
    
}

Solution

  • We are comparing apples-to-oranges. The task group implementation is performing createSubIssue calls in parallel, but the async let rendition, surprisingly, is not. This stems from a mistake async let rendition (failing to ever await) that is preventing parallel execution. And when we fix that in the async let rendition (putting it on equal footing with the task group rendition), it (unfortunately) exhibits the exact same problem.


    To illustrate this, let us add instrumentation to all the functions in BackgroundClass, so we can profile the app with Instruments’ “Time Profiler” template and see what is going on in a timeline:

    //  BackgroundClass.swift
    
    import Foundation
    import SwiftData
    import os.log
    
    // MARK: Class with the async methods that are called
    final class BackgroundClass: Sendable {
        let poi = OSSignposter(subsystem: "BackgroundClass", category: .pointsOfInterest)
    
        public func asyncTaskGroup(in container: ModelContainer) async -> (error: String?, issueID: PersistentIdentifier?) {
            let state = poi.beginInterval(#function, id: poi.makeSignpostID())
            defer { poi.endInterval(#function, state) }
    
            …
    
            // print("Main TG: Before creating the count is \(issue.subissues.count)")
    
            poi.emitEvent(#function, "Main TG: Before creating the count is \(issue.subissues.count)")
    
            …
        }
    
        public func asyncLetTest(in container: ModelContainer) async -> (error: String?, issueID: PersistentIdentifier?) {
            let state = poi.beginInterval(#function, id: poi.makeSignpostID())
            defer { poi.endInterval(#function, state) }
    
            …
        }
    
        …
    }
    

    When we do that, and we profile the app and watch the task group rendition, we see it trying to add the subissues in parallel:

    TG is in parallel

    But if we look at the async let rendition, they are not running in parallel:

    AL without await not in parallel

    The problem in the async let rendition is that we never await the results of the tasks. When you do async let, you must always await the result or else the task will be canceled and be implicitly awaited when the async let falls out of scope. (The purpose of this is to ensure that, for example, if a routine has an early exit, that the now-unnecessary asynchronous work stops.) See the Implicit async let awaiting discussion in SE-0317.

    To fix the async let rendition, let’s await the tasks:

    internal final func createSubissues(issue: Issue) async {
        let state = poi.beginInterval(#function, id: poi.makeSignpostID())
        defer { poi.endInterval(#function, state) }
    
        let modelContainer = issue.modelContext!.container
        let thisID = issue.persistentModelID
        poi.emitEvent(#function, "create: Before creating the count is \(issue.subissues.count)")
    
        // for i in 1...15 {
        //     async let _ = createSubIssue(in: modelContainer, issueID: thisID, name: i)
        // }
    
        async let task1 = createSubIssue(in: modelContainer, issueID: thisID, name: 1)
        async let task2 = createSubIssue(in: modelContainer, issueID: thisID, name: 2)
        async let task3 = createSubIssue(in: modelContainer, issueID: thisID, name: 3)
        async let task4 = createSubIssue(in: modelContainer, issueID: thisID, name: 4)
        async let task5 = createSubIssue(in: modelContainer, issueID: thisID, name: 5)
        async let task6 = createSubIssue(in: modelContainer, issueID: thisID, name: 6)
        async let task7 = createSubIssue(in: modelContainer, issueID: thisID, name: 7)
        async let task8 = createSubIssue(in: modelContainer, issueID: thisID, name: 8)
        async let task9 = createSubIssue(in: modelContainer, issueID: thisID, name: 9)
        async let task10 = createSubIssue(in: modelContainer, issueID: thisID, name: 10)
        async let task11 = createSubIssue(in: modelContainer, issueID: thisID, name: 11)
        async let task12 = createSubIssue(in: modelContainer, issueID: thisID, name: 12)
        async let task13 = createSubIssue(in: modelContainer, issueID: thisID, name: 13)
        async let task14 = createSubIssue(in: modelContainer, issueID: thisID, name: 14)
        async let task15 = createSubIssue(in: modelContainer, issueID: thisID, name: 15)
    
        _ = await [task1, task2, task3, task4, task5, task6, task7, task8, task9, task10, task11, task12, task13, task14, task15]
    
        poi.emitEvent(#function, "create: After creating the count is \(issue.subissues.count)")
    }
    

    When we profile it now, we do see parallel execution:

    timeline of AL with parallelism

    But unfortunately, we also now see it manifesting the same problem as the task group example. In the following screen snapshot, the first AL is the original async let rendition, and the following TG entries are the task group rendition. The last AL, though, is with my rendition of the async let with the necessary await calls. As you will notice, this will now also drop a record:

    now AL is parallelized, too (but wrong)


    Now that we are comparing apples-to-apples, the question is how to fix this “parallel insert” process. I might advise cutting the Gordian knot, and not attempt to run these in parallel at all. For example, here I created a rendition that just inserts the records sequentially. (I also changed createSubIssue not to be an async function given that it does not await anything and the only utility of a nonisolated async function is to get it off the current thread, which is not necessary if no longer trying to do this in parallel.)

    When I did that, the serial rendition is actually a fraction faster. Compare the average time elapsed for the three renditions, the (corrected) async let, the task group, and the non-parallel rendition, performing each four times:

    instruments w AL, TG, and serial

    And not only is the sequential rendition slightly faster, but it successfully inserts all fifteen subissues successfully:

    results of AL, TG, and serial

    Given that all of this “parallel” work is all within the same routine, the non-parallel rendition is fine. When I really have diverse asynchronous jobs trying to update the same database, I use ModelActor to synchronize the interaction with this shared resource.