Skip to content

Conversation

@petkybenedek
Copy link
Contributor

@petkybenedek petkybenedek commented Jan 21, 2026

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

refs: MBL-19677
builds: Student, Teacher, Parent
affects: Student, Teacher, Parent
release note: none
Copy link

@claude claude bot left a 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 performWriteTask methods are using self.context instead of the computed context property, 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 forceRefresh method silently ignores errors with try?. 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 AsyncStore provides a modern, ergonomic interface that will improve code readability compared to Combine publishers.

  • Offline mode support: Proper integration with OfflineModeInteractor ensures consistent behavior across online/offline states.

  • Smart caching: The cache validation logic is well-implemented with hasCacheExpired checks before making API calls.

  • Consistent error handling: Most methods properly propagate errors through async throws, giving callers control over error handling.

  • Pagination support: The loadAllPages parameter provides flexibility for different data loading scenarios.

  • Good separation of concerns: Private helper methods like fetchEntitiesFromCache, fetchEntitiesFromAPI, and fetchEntitiesFromDatabase keep the code organized and testable.

Recommendations

  1. Fix the critical bug: The performWriteTask implementation must use the write context, not the view context.

  2. 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.

  3. Consider adding cancellation helpers: For non-terminating streams, provide convenience methods that handle cancellation automatically (e.g., with timeouts or lifecycle-bound cancellation).

  4. Add logging: At minimum, log errors in forceRefresh to aid debugging.

  5. 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) }
Copy link

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) }
Copy link

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) }

@petkybenedek petkybenedek changed the title [MBL-19677] [S/P/T] Structured Concurrency foundations [MBL-19677][S/P/T] Structured Concurrency foundations Jan 21, 2026
@inst-danger
Copy link
Contributor

inst-danger commented Jan 21, 2026

Warnings
⚠️ This pull request will not generate a release note.

Affected Apps: Student, Teacher, Parent

Builds: Student, Teacher, Parent

MBL-19677

Coverage New % Master % Delta
Canvas iOS 91.4% 81.06% 10.33%

Generated by 🚫 dangerJS against d481da6

@inst-danger
Copy link
Contributor

inst-danger commented Jan 21, 2026

Builds

Commit: Correct tests (d481da6)
Build Number: 1260
Built At: Feb 05 15:50 CET (02/05 07:50 AM MST)

Student
Teacher
Parent

@petkybenedek petkybenedek self-assigned this Jan 22, 2026
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 }
Copy link
Contributor

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 ?

Copy link
Contributor Author

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.

Copy link
Contributor

@suhaibabsi-inst suhaibabsi-inst left a 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
Copy link
Contributor

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.

Copy link
Contributor Author

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> {
Copy link
Contributor

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()`.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Copy link
Contributor

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.

Comment on lines 152 to 155
let scope = useCase.scope
let request = NSFetchRequest<U.Model>(entityName: String(describing: U.Model.self))
request.predicate = scope.predicate
request.sortDescriptors = scope.order
Copy link
Contributor

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
    }

Copy link
Contributor Author

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
Copy link
Contributor

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.

Copy link
Contributor Author

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.

petkybenedek and others added 3 commits February 2, 2026 09:33
Copy link
Contributor

@suhaibabsi-inst suhaibabsi-inst left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code +1

Copy link
Collaborator

@vargaat vargaat left a 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 await finishes we are on a background thread, from where accessing a main thread bound managed object potentially crashes.
Image

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.

Image

@petkybenedek
Copy link
Contributor Author

It seems to me that the problem occurs outside of AsyncStore's scope. Marking the AsyncStoreTests with @MainActor thus ensuring that the tests are running on the main thread solves the crash issues.

refs:
builds:
affects:
release note:

test plan:
@vargaat
Copy link
Collaborator

vargaat commented Feb 5, 2026

It seems to me that the problem occurs outside of AsyncStore's scope. Marking the AsyncStoreTests with @MainActor thus ensuring that the tests are running on the main thread solves the crash issues.

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.

@petkybenedek
Copy link
Contributor Author

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
Copy link
Contributor

@rh12 rh12 left a 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!

@rh12 rh12 marked this pull request as draft February 6, 2026 18:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants