In a Combine Publisher chain, how to keep inner objects alive until cancel or complete? - swift

I've created a Combine publisher chain that looks something like this:
let pub = getSomeAsyncData()
.mapError { ... }
.map { ... }
...
.flatMap { data in
let wsi = WebSocketInteraction(data, ...)
return wsi.subject
}
.share().eraseToAnyPublisher()
It's a flow of different possible network requests and data transformations. The calling code wants to subscribe to pub to find out when the whole asynchronous process has succeeded or failed.
I'm confused about the design of the flatMap step with the WebSocketInteraction. That's a helper class that I wrote. I don't think its internal details are important, but its purpose is to provide its subject property (a PassthroughSubject) as the next Publisher in the chain. Internally the WebSocketInteraction uses URLSessionWebSocketTask, talks to a server, and publishes to the subject. I like flatMap, but how do you keep this piece alive for the lifetime of the Publisher chain?
If I store it in the outer object (no problem), then I need to clean it up. I could do that when the subject completes, but if the caller cancels the entire publisher chain then I won't receive a completion event. Do I need to use Publisher.handleEvents and listen for cancellation as well? This seems a bit ugly. But maybe there is no other way...
.flatMap { data in
let wsi = WebSocketInteraction(data, ...)
self.currentWsi = wsi // store in containing object to keep it alive.
wsi.subject.sink(receiveCompletion: { self.currentWsi = nil })
wsi.subject.handleEvents(receiveCancel: {
wsi.closeWebSocket()
self.currentWsi = nil
})
Anyone have any good "design patterns" here?
One design I've considered is making my own Publisher. For example, instead of having WebSocketInteraction vend a PassthroughSubject, it could conform to Publisher. I may end up going this way, but making a custom Combine Publisher is more work, and the documentation steers people toward using a subject instead. To make a custom Publisher you have to implement some of things that the PassthroughSubject does for you, like respond to demand and cancellation, and keep state to ensure you complete at most once and don't send events after that.
[Edit: to clarify that WebSocketInteraction is my own class.]

It's not exactly clear what problems you are facing with keeping an inner object alive. The object should be alive so long as something has a strong reference to it.
It's either an external object that will start some async process, or an internal closure that keeps a strong reference to self via self.subject.send(...).
class WebSocketInteraction {
private let subject = PassthroughSubject<String, Error>()
private var isCancelled: Bool = false
init() {
// start some async work
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if !isCancelled { self.subject.send("Done") } // <-- ref
}
}
// return a publisher that can cancel the operation when
var pub: AnyPublisher<String, Error> {
subject
.handleEvents(receiveCancel: {
print("cancel handler")
self.isCancelled = true // <-- ref
})
.eraseToAnyPublisher()
}
}
You should be able to use it as you wanted with flatMap, since the pub property returned publisher, and the inner closure hold a reference to self
let pub = getSomeAsyncData()
...
.flatMap { data in
let wsi = WebSocketInteraction(data, ...)
return wsi.pub
}

Related

Best way to call GlobalActor function from NotificationCenter Publisher

I have a GlobalActor with some methods on it that I'm using throughout my app. I'd like to be able to call a function from the actor when I receive a Notification from NotificationCenter, but can't call async functions from sink.
This is what I'm doing now:
class MyClass {
private var cancellables: [AnyCancellable] = []
init() {
NotificationCenter.default.publisher(for: NotificationName)
.receive(on: DispatchQueue.global(qos: .utility))
.compactMap { $0 as? SomeType }
.sink { [weak self] val in
Task { [weak self] in
await self?.someCallToActor(val)
}
}.store(in: &cancellables)
}
#SomeGlobalActor
func someCallToActor(_ val: String) async {
await SomeGlobalActor.shared.actorMethod(val)
}
}
...
#globalActor
actor SomeGlobalActor {
static var shared = SomeGlobalActor()
func actorMethod(_ val: String) async {
...
}
}
Calling Task within a closure here feels wrong and potentially race-condition-y. Is this the best way to accomplish what I'm trying to? I've tried receiving the notifications inside of the actor itself but it doesn't change much. The issue is the closure provided to sink is meant to be synchronous so I can't await inside of it.
The only way to get the Actor to do something is to put a message in its mail queue. The actor handles messages one at a time, in the order received. Every message that goes into the queue gets a response. Code can only put a message in the queue if it's willing, and able, to wait around for the response. The sink function can't wait around, it has other things to do (i.e. handle the next incoming messages from a Publisher). It needs an intermediary to do the waiting for it. The Task is that intermediary.
Note that the actor only prevents race conditions on the actor's state. As your intuition suggests, you could still have the "high-level" race condition of two messengers (two Tasks) racing to see who puts their item in Actor's mail queue first. But within the actor, there will be a strict ordering to the changes made by the two messages. (preventing low-level data races on the Actor's state)
Unfortunately the order of execution of independent Tasks, like the individual tasks created by your sink, is arbitrary. Your code could process notifications out-of-order.
To solve the problems you need to serialize the order in which the notifications are received and then delivered to the actor. To do that you need one Task, one messenger, doing both jobs – receiving the notifications and passing them on to the actor.
NotificationCenter allows you to receive the notifications as an AsyncSequence. So instead of getting messages as a publisher, you could get them from a sequence. Something like this:
class MyClass {
let notificationTask: Task<Void, Never>
init() {
notificationTask = Task {
for await notification in NotificationCenter.default.notifications(named: interestingNotification) {
guard !Task.isCancelled else { return }
if let value = notification.userInfo?[0] as? String {
await someActor.actorMethod(value)
}
}
}
}
}
Here the Task waits to receive a message from the notification center. When it gets one, it does some transformations on it (pulling values out of userInfo in this case) then it hands the transformed message over to the actor. The notifications arrival is serialized by the async sequence and the task makes sure that they arrive to the actor in the same order.

How do I wait for a download to complete before continuing?

I have this block of code. It fetches data from the API and adds it to a locationDetails array, which is part of a singleton.
private func DownloadLocationDetails(placeID: String) {
let request = AF.request(GoogleAPI.shared.getLocationDetailsLink(placeID: placeID))
request.responseJSON { (data) in
guard let detail = try? JSONDecoder().decode(LocationDetailsBase.self, from: data.data!),
let result = detail.result else {
print("Something went wrong fetching nearby locations.")
return
}
DownloadManager.shared.locationDetails.append(result)
}
}
This block of code is the block in question. I'm creating a caching system of sorts that only downloads new information and retains any old information. This is being done to save calls to the API and for performance gains. The line DownloadLocationDetails(placeID: placeID) is a problem for me because if I execute this line of code it will continue to loop over and over again using unnecessary API calls while waiting for the download to complete. How do I effectively manage this?
func GetLocationDetail(placeID: String) -> LocationDetail {
for location in locationDetails {
if location.place_id == placeID { return location }
}
DownloadLocationDetails(placeID: placeID)
return GetLocationDetail(placeID: placeID)
}
I expect this GetLocationDetail(....) to be called whenever a user interacts with an interface object, so how do I also ensure that the view that calls this is properly notified that the download is complete?
I attempted using a closure but I can't get it to return the way I'm wanting it to. I have a property on the singleton that I want to set this value so that it can be called globally. I am also considering using GCD but I'm not sure of the structure for that.
Generally the pattern for something like this is to store the request object you created in DownloadLocationDetails so you can check to see if one is active before making another call. If you only want to support one at a time, then it's as simple as keeping the bare reference to the request object, but you could make a dictionary of request objects keyed off the placeID (and you probably want to think about maximum request count, and queue up additional requests).
Then the trick is to get notified when the given request object completes. There are a couple ways you could do this, such as keeping a list of callbacks to invoke when it completes, but the easiest would probably be just to refactor the code a bit so that you always update your UI when the request completes, so something like:
private func DownloadLocationDetails(placeID: String) {
let request = AF.request(GoogleAPI.shared.getLocationDetailsLink(placeID: placeID))
request.responseJSON { (data) in
guard let detail = try? JSONDecoder().decode(LocationDetailsBase.self, from: data.data!),
let result = detail.result else {
print("Something went wrong fetching nearby locations.")
return
}
DownloadManager.shared.locationDetails.append(result)
// Notify the UI to refresh for placeID
}
}

Swift Combine erase array of publishers into AnyCancellable

Is it possible to fire multiple requests which return a Publisher and be able to cancel them without sink?
I would like to combine the requests into a single cancellable reference or store each one if possible without sink (code below). Is this possible?
func fetchDetails(for contract: String) -> AnyPublisher<String, Error>
Fire Multiple requests and store
#State var cancellable: Set<AnyCancellable> = []
let promises = items.map {
self.fetchFeed.fetchDetails(for: $0.contract)
}
Publishers.MergeMany(promises)
.sink(receiveCompletion: { _ in }, receiveValue: { _ in }) // ** is this required?
.store(in: &cancellable)
It really depends on what fetchDetails does to create the publisher. Almost every publisher provided by Apple has no side effects until you subscribe to it. For example, the following publishers have no side effects until you subscribe to them:
NSObject.KeyValueObservingPublisher (returned by NSObject.publisher(for:options:)
NotificationCenter.Publisher (returned by NotificationCenter.publisher(for:object:)
Timer.TimerPublisher (returned by Timer.publishe(every:tolerance:on:in:options:)
URLSession.DataTaskPublisher (returned by URLSession.dataTaskPublisher(for:)
The synchronous publishers like Just, Empty, Fail, and Sequence.Publisher.
In fact, the only publisher that has side effects on creation, as far as I know, is Future, which runs its closure immediately on creation. This is why you'll often see the Deferred { Future { ... } } construction: to avoid immediate side effects.
So, if the publisher returned by fetchDetails behaves like most publishers, you must subscribe to it to make any side effects happen (like actually sending a request over the network).

Concurrency issues with observable.observeOn() and common resources

I have an observable inside a function.
The function happens in a certain queue, queueA, and the observable is subscribed to with observeOn(schedulerB). In onNext, I'm changing a class variable.
In another function, I'm changing the same class variable, from a different queue.
Here is some code to demonstrate my situation:
class SomeClass {
var commonResource: [String: String] = [:]
var queueA = DispatchQueue(label: "A")
var queueB = DispatchQueue(label: "B")
var schedulerB = ConcurrentDispatchQueueScheduler(queue: QueueB)
func writeToResourceInOnNext() {
let obs: PublishSubject<String> = OtherClass.GetObservable()
obs.observeOn(schedulerB)
.subscribe(onNext: { [weak self] res in
// this happens on queue B
self.commonResource["key"] = res
}
}
func writeToResource() {
// this happens on queue A
commonResource["key"] = "otherValue"
}
}
My question is, is it likely to have concurrency issues, if commonResource is modified in both places at the same time?
What is the common practice for writing/reading from class/global variables inside onNext in an observable with observeOn?
Thanks all!
Since your SomeClass has no control over when these functions will be called or on what threads the answer is yes, you are setup to have concurrency issues in this code due to its passive nature.
The obvious solution here is to dispatch to queue B inside writeToResource() in order to avoid the race condition.
Another option would be to use an NSLock (or NSRecursiveLock) and lock it before you write to the resource and unlock it after.
The best practice is: when you have a side effect happening inside a subscribe function's closure (in this case writing to commonResource that the closure is the only place where the side effect occurs. This would mean doing away with the passive writeToResource() function and instead passing in an Observable that was generated by whatever code currently is calling the function.

Combine: How to clean up resources while an AnyCancellable is being cancelled?

Overview:
I have a async task to fetch from the database
I have created a Future for the async task (fetching from the database).
Question:
How can execute custom code when the Future is cancelled?
Purpose:
I would like the database connection to be closed when the subscription is cancelled.
For example, I would like to use Combine to rewrite this helper method:
// Similar to https://developer.apple.com/documentation/coredata/nspersistentcontainer/1640564-performbackgroundtask
func withDatabaseFTSContext(block: #escaping (FMDatabase?) -> Void) {
queue.async {
guard let database = self.database else {
block(nil)
return
}
database.open()
let simpleTokenizer = FMSimpleTokenizer(locale: nil)
FMDatabase.registerTokenizer(simpleTokenizer, withKey: "simple")
database.installTokenizerModule()
block(database)
database.close()
}
}
Could I leverage Combine to rewrite this method to return FMDatabase as a parameter of a publisher?
I was attempting to use Combine but it does not work. The database will be closed before cancel()
private func withDatabaseFTSContext() -> AnyPublisher<FMDatabase?, Never> {
return Future<FMDatabase?, Never> { promise in
self.queue.async {
guard let database = self.database else {
promise(.success(nil))
return
}
database.open()
let simpleTokenizer = FMSimpleTokenizer(locale: nil)
FMDatabase.registerTokenizer(simpleTokenizer, withKey: "simple")
database.installTokenizerModule()
promise(.success(database))
database.close() // When to close this database? Currently it will be closed before `cancel()`
}
}.eraseToAnyPublisher()
}
Short answer: there isn't a callback that triggers through to the underlying Future that you can use to clean things up on a subscriber cancel. In the Combine design, these functions are very intentionally separated and don't have reference links back to their publishers.
(In addition, Future is a tricky figure in the Combine world because the closure is invoked immediately upon creation time, rather than when you have a subscription (if you want that, wrap in the Future publisher in a Deferred publisher)).
All that being said, what you likely want to do to solve your underlying problem is reframe how you're treating this to separate the concerns of managing the FMDB instance and publishing data. One pattern that's been reasonably useful in this context is to the make an object that holds the lifetime of the FMDB reference, and handle cleaning up resources on it's deinit(). You can then also have a function which vends a Publisher of whatever you need from that same object, and then the cancellation of the request is changed semantically to only cancelling getting the database, not cancelling and cleaning up the database connection.