-
Notifications
You must be signed in to change notification settings - Fork 121
[MBL-19677][S/P/T] Structured Concurrency foundations #3852
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
[MBL-19677][S/P/T] Structured Concurrency foundations #3852
Conversation
refs: MBL-19677 builds: Student, Teacher, Parent affects: Student, Teacher, Parent release note: none
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Review Summary
This PR introduces a new AsyncStore implementation that provides async/await alternatives to the existing reactive Store pattern. The changes align well with Swift's modern concurrency model and follow the project's MVVM architecture.
Issues Found
-
Critical Bug in NSPersistentContainerExtensions.swift:99-103 - The
performWriteTaskmethods are usingself.contextinstead of the computedcontextproperty, which defeats the purpose of having a dedicated write context. This could lead to threading issues. -
Memory Leak Risk in AsyncStore.swift:139 - The
streamEntitiesFromDatabase()method returns a non-terminating stream without built-in cancellation handling. While the warning comment is present, consider adding safeguards or helper methods. -
Silent Error Handling in AsyncStore.swift:147 - The
forceRefreshmethod silently ignores errors withtry?. This should at least log errors for debugging purposes. -
SwiftLint Violation in AsyncStore.swift:54 - Line length exceeds the project's limit and should be broken into multiple lines.
Positive Aspects
-
Clean async/await API: The new
AsyncStoreprovides a modern, ergonomic interface that will improve code readability compared to Combine publishers. -
Offline mode support: Proper integration with
OfflineModeInteractorensures consistent behavior across online/offline states. -
Smart caching: The cache validation logic is well-implemented with
hasCacheExpiredchecks before making API calls. -
Consistent error handling: Most methods properly propagate errors through
async throws, giving callers control over error handling. -
Pagination support: The
loadAllPagesparameter provides flexibility for different data loading scenarios. -
Good separation of concerns: Private helper methods like
fetchEntitiesFromCache,fetchEntitiesFromAPI, andfetchEntitiesFromDatabasekeep the code organized and testable.
Recommendations
-
Fix the critical bug: The
performWriteTaskimplementation must use the write context, not the view context. -
Add unit tests: Given the complexity of async streams and Core Data operations, comprehensive unit tests are essential. Follow the conventions in CLAUDE-unit-tests.md.
-
Consider adding cancellation helpers: For non-terminating streams, provide convenience methods that handle cancellation automatically (e.g., with timeouts or lifecycle-bound cancellation).
-
Add logging: At minimum, log errors in
forceRefreshto aid debugging. -
SwiftLint compliance: Ensure all code passes SwiftLint checks before merging.
|
|
||
| @objc open func performWriteTask(_ block: @escaping (NSManagedObjectContext) -> Void) { | ||
| let context = writeContext ?? { | ||
| context.perform { block(self.context) } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: This implementation is incorrect. The method is supposed to use a write context, but it's always using self.context instead of the actual context variable returned by the context computed property.
The block(self.context) should be block(context) where context is assigned from the context computed property.
| } | ||
|
|
||
| public func performWriteTask<T>(_ block: @escaping (NSManagedObjectContext) throws -> T) async rethrows -> T { | ||
| return try await context.perform { try block(self.context) } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Same issue here - this should use the write context from the context computed property, not self.context. The correct implementation should be:
let writeContext = context
return try await writeContext.perform { try block(writeContext) }
BuildsCommit: Correct tests (d481da6) |
| let entities = try await getEntities(ignoreCache: ignoreCache, loadAllPages: loadAllPages) | ||
|
|
||
| if assertOnlyOneEntityFound, entities.count > 1 { throw AsyncStoreError.moreThanOneEntityFound(entities.count) } | ||
| guard let entity = entities.first else { throw AsyncStoreError.noEntityFound } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, here we are treating the empty data (no entities) as failure. Wouldn't that confuse code at call sites? As that would often be treated as a separate case in terms of UI ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The other option would be to return nil, which is equivalent of just calling first on getEntities. This function is specifically created for cases when we expect an entity.
You can catch the specific error if you want to handle that case specifically, or just use first.
But I'm open to discussion, but if we want to return an optional, we can get rid of this function and just use first.
suhaibabsi-inst
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@petkybenedek Great Work! I liked how AsyncFetchedResults turned to be. Though I have a couple of comments to consider to fully utilize the syntactic beauty of async/await coding.
| import Foundation | ||
| import CoreData | ||
|
|
||
| @preconcurrency |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking if we can get rid of this @preconcurrency attribute be used with our newly introduced Async types. How about using it with @preconcurrency import CoreData as that was by Apple, and then move the fetch method to an extension of NSManagedObjectContext.
@preconcurrency import CoreData
public final class AsyncFetchResults<ResultType: NSFetchRequestResult> {
....
public func fetch() async throws -> [ResultType] {
try await context.fetch(request)
}
}
extension NSManagedObjectContext {
public func fetch<R: NSFetchRequestResult>(_ request: NSFetchRequest<R>) async throws -> [R] {
try await perform {
return try self.fetch(request)
}
}
}This way we keep the new code committed to the async/await model, isolating any source of pre-concurrency as much as possible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea, thanks!
| /// Defaults to **false**. | ||
| /// - loadAllPages: Tells the request if it should load all the pages or just the first one. Defaults to **true**. | ||
| /// - Returns: An async sequence of list of entities. | ||
| public func streamEntities(ignoreCache: Bool = false, loadAllPages: Bool = true) async throws -> AsyncThrowingStream<[U.Model], Error> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about renaming it to be more indicative of watching store updates. Something like:
updatesStream(), or simply ``updates()`.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would name it back to streamEntities() because IMHO updates() is more confusing, as I would kind of expect it to update something or how can it return "updates". Also considered streamUpdates() or streamEntityUpdates(), but still prefer the original naming.
"Stream" is also what is being actually returned.
| let scope = useCase.scope | ||
| let request = NSFetchRequest<U.Model>(entityName: String(describing: U.Model.self)) | ||
| request.predicate = scope.predicate | ||
| request.sortDescriptors = scope.order |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can be refactored out to a computed property or set at the initializer:
private var request: NSFetchRequest<U.Model> {
let scope = useCase.scope
let request = NSFetchRequest<U.Model>(entityName: String(describing: U.Model.self))
request.predicate = scope.predicate
request.sortDescriptors = scope.order
return request
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
| try Task.checkCancellation() | ||
|
|
||
| return try await withCheckedThrowingContinuation { continuation in | ||
| self.executeFetch(environment: environment) { result in |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps for later occasion:
It would be great if we can have a full-fledged async version of executeFetch function.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, we could also propagate down task cancellation to URLSession and we could make use of actors regarding token refresh. I kept it simple for this PR, but certainly an improvement worth making in the future.
Co-authored-by: Suhaib Al-Absi <[email protected]>
Co-authored-by: Suhaib Al-Absi <[email protected]>
suhaibabsi-inst
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code +1
vargaat
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My concern is that CoreData thread safety is not ensured with the current solution.
Consider the screenshot below:
- The course was fetched on the view context (see result of
po). The view context is associated with the main thread. - When
awaitfinishes we are on a background thread, from where accessing a main thread bound managed object potentially crashes.
If you start running the AsyncStoreTests repeatedly then it will crash after a while. On the below crash we can see that we are asked to work on the main view context, yet the code is executing on a background thread, thus the crash.
|
It seems to me that the problem occurs outside of |
refs: builds: affects: release note: test plan:
This annotation will solve this particular problem. But if we modify the test's context not to run on the viewcontext but on a background context we will face the same issue (test running on the main thread while the object is from a background one). If we keep fetching CoreData on the main thread in the app, that will mean that every method using these async fetches should be @mainactor annotated which could result in a cascading warning tsunami. Also, fetching on a background thread would still not be possible and that would be a goal of ours to lower the load on the main thread in the future. |
|
What would be the solution here? Mapping Core Data objects to structs perhaps? |
renamed, rearranged methods updated forceRefresh() updated fetchEntitiesFromAPI() to just fetch to database and not return entities
rh12
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code + 1
Thanks for the cleanup on AsyncStore!
What's new
This PR introduces AsyncStore, which makes use of structured concurrency to interact with the API and Core Data.
refs: MBL-19677
builds: Student, Teacher, Parent
affects: Student, Teacher, Parent
release note: none