Why is this Combine pipeline not letting items through? - ios

I'm stuck on a Combine problem, and I can't find a proper solution for this.
My goal is to monitor a queue, and process items in the queue until it's empty. If then someone adds more items in the queue, I resume processing. Items needs to be processed one by one, and I don't want to lose any item.
I wrote a very simplified queue below to reproduce the problem. My items are modeled as just strings for the sake of simplicity again.
Given the contraints above:
I use a changePublisher on the queue to monitor for changes.
A button lets me add a new item to the queue
The flatMap operator relies on the maxPublishers parameter to only allow one in-flight processing.
The buffer operator prevents items from being lost if the flatMap is busy.
Additionally, I'm using a combineLatest operator to only trigger the pipeline under some conditions. For simplicity, I'm using a Just(true) publisher here.
The problem
If I tap the button, a first item goes in the pipeline and is processed. The changePublisher triggers because the queue is modified (item is removed), and the pipeline stops at the compactMap because the peek() returns nil. So far, so good. Afterwards, though, if I tap on the button again, a value is sent in the pipeline but never makes it through the buffer.
Solution?
I noticed that removing the combineLatest prevents the problem from happening, but I don't understand why.
Code
import Combine
import UIKit
class PersistentQueue {
let changePublisher = PassthroughSubject<Void, Never>()
var strings = [String]()
func add(_ s: String) {
strings.append(s)
changePublisher.send()
}
func peek() -> String? {
strings.first
}
func removeFirst() {
strings.removeFirst()
changePublisher.send()
}
}
class ViewController: UIViewController {
private let queue = PersistentQueue()
private var cancellables: Set<AnyCancellable> = []
override func viewDidLoad() {
super.viewDidLoad()
start()
}
#IBAction func tap(_ sender: Any) {
queue.add(UUID().uuidString)
}
/*
Listen to changes in the queue, and process them one at a time. Once processed, remove the item from the queue.
Keep doing this until there are no more items in the queue. The pipeline should also be triggered if new items are
added to the queue (see `tap` above)
*/
func start() {
queue.changePublisher
.print("Change")
.buffer(size: Int.max, prefetch: .keepFull, whenFull: .dropNewest)
.print("Buffer")
// NOTE: If I remove this combineLatest (and the filter below, to make it compile), I don't have the issue anymore.
.combineLatest(
Just(true)
)
.print("Combine")
.filter { _, enabled in return enabled }
.print("Filter")
.compactMap { _ in
self.queue.peek()
}
.print("Compact")
// maxPublishers lets us process one page at a time
.flatMap(maxPublishers: .max(1)) { reference in
return self.process(reference)
}
.sink { reference in
print("Sink for \(reference)")
// Remove the processed item from the queue. This will also trigger the queue's changePublisher,
// which re-run this pipeline in case
self.queue.removeFirst()
}
.store(in: &cancellables)
}
func process(_ value: String) -> AnyPublisher<String, Never> {
return Future<String, Never> { promise in
print("Starting processing of \(value)")
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 2) {
promise(.success(value))
}
}.eraseToAnyPublisher()
}
}
Output
Here is a sample run of the pipeline if you tap on the button twice:
Change: receive subscription: (PassthroughSubject)
Change: request max: (9223372036854775807)
Buffer: receive subscription: (Buffer)
Combine: receive subscription: (CombineLatest)
Filter: receive subscription: (Print)
Compact: receive subscription: (Print)
Compact: request max: (1)
Filter: request max: (1)
Combine: request max: (1)
Buffer: request max: (1)
Change: receive value: (())
Buffer: receive value: (())
Combine: receive value: (((), true))
Filter: receive value: (((), true))
Compact: receive value: (3999C98D-4A86-42FD-A10C-7724541E774D)
Starting processing of 3999C98D-4A86-42FD-A10C-7724541E774D
Change: request max: (1) (synchronous)
Sink for 3999C98D-4A86-42FD-A10C-7724541E774D // First item went through pipeline
Change: receive value: (())
Compact: request max: (1)
Filter: request max: (1)
Combine: request max: (1)
Buffer: request max: (1)
Buffer: receive value: (())
Combine: receive value: (((), true))
Filter: receive value: (((), true))
// Second time compactMap is hit, value is nil -> doesn't forward any value downstream.
Filter: request max: (1) (synchronous)
Combine: request max: (1) (synchronous)
Change: request max: (1)
// Tap on button
Change: receive value: (())
// ... Nothing happens
[EDIT] Here is a much more constrained example, which can run in Playgrounds and which also demonstrates the problem:
import Combine
import Foundation
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
func process(_ value: String) -> AnyPublisher<String, Never> {
return Future<String, Never> { promise in
print("Starting processing of \(value)")
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.1) {
promise(.success(value))
}
}.eraseToAnyPublisher()
}
var count = 3
let s = PassthroughSubject<Void, Never>()
var cancellables = Set<AnyCancellable>([])
// This reproduces the problem. Switching buffer and combineLatest fix the problem…
s
.print()
.buffer(size: Int.max, prefetch: .keepFull, whenFull: .dropNewest)
.combineLatest(Just("a"))
.filter { _ in count > 0 }
.flatMap(maxPublishers: .max(1)) { _, a in process("\(count)") }
.sink {
print($0)
count -= 1
s.send()
}
.store(in: &cancellables)
s.send()
Thread.sleep(forTimeInterval: 3)
count = 1
s.send()
Switching combine and buffer fixes the problem.

I am not sure why the pipeline is blocked, but there is no reason to publish when the queue is empty. Fixing this resolved the problem for me.
func removeFirst() {
guard !strings.isEmpty else {
return
}
strings.removeFirst()
if !self.strings.isEmpty {
self.changePublisher.send(self.strings.first)
}
}

Just tried your example. It works as expected if buffer is placed before flatMap. And update removeFirst according to Paulw11's answer below
queue.changePublisher
.print("Change")
// NOTE: If I remove this combineLatest (and the filter below, to make it compile), I don't have the issue anymore.
.combineLatest(Just(true))
.print("Combine")
.filter { _, enabled in return enabled }
.print("Filter")
.compactMap { _ in
self.queue.peek()
}
.print("Compact")
// maxPublishers lets us process one page at a time
.buffer(size: Int.max, prefetch: .keepFull, whenFull: .dropNewest)
.print("Buffer")
.flatMap(maxPublishers: .max(1)) { reference in
return self.process(reference)
}
.sink { reference in
print("Sink for \(reference)")
// Remove the processed item from the queue. This will also trigger the queue's changePublisher,
// which re-run this pipeline in case
self.queue.removeFirst()
print("COUNT: " + self.queue.strings.count.description)
}
.store(in: &cancellables)

Related

How to make restartable countdown using Rxswift

I want to create a countdown feature that restart every time I press a button.
However, the code I wrote terminate the subscription when the countdown is completed.
What can I do to ensure that my subscription is not terminated and the countdown is restarted?
fileprivate let counter = 10
fileprivate let startCountDown = PublishRelay<Void>()
startCountDown
.flatMapLatest { _ -> Observable<Int> in
return Observable<Int>.timer(.seconds(0), period: .seconds(1), scheduler: MainScheduler.instance)
}
.take(counter + 1)
.subscribe(onNext: { time in
print(time)
}, onCompleted: {
print("Completed")
})
.disposed(by: rx.disposeBag)
When take(_:) completes, the Observable chain is disposed. However, if the Observable inside the flatMapLatest closure completes, the chain is not disposed because startCountDown hasn't completed yet. The flatMapLatest observable won't complete until all the observables it subscribes to completes. So the solution is to put the take(_:) inside the flatMapLatest.
The View Model should look like this:
func startCountDown(counter: Int, trigger: Observable<Void>, scheduler: SchedulerType) -> Observable<Int> {
trigger
.flatMapLatest {
Observable<Int>.timer(.seconds(0), period: .seconds(1), scheduler: scheduler)
.take(counter + 1)
}
}
You use the above view model in your view controller like this:
startCountdown(counter: 10, trigger: startButton.rx.tap.asObservable(), scheduler: MainScheduler.instance)
.subscribe(onNext: { time in
print(time)
})
.disposed(by: rx.disposeBag)
Strictly speaking, this is a count-up timer. It will go from 0 to counter and then wait until the button is tapped again. If the button is tapped while it is counting, it will restart. If you want it to ignore taps until it's done counting, then use flatMapFirst instead.
Learn more in by reading this article: RxSwift's Many Faces of FlatMap
As a bonus, here's how you can test the view model:
final class CountdownTests: XCTestCase {
func test() {
let scheduler = TestScheduler(initialClock: 0)
let trigger = scheduler.createObservable(timeline: "--V---V-|", values: ["V": ()])
let expected = parseEventsAndTimes(timeline: "---012-0123456789|", values: { Int(String($0))! })
let result = scheduler.start(created: 0, subscribed: 0, disposed: 100) {
startCountDown(counter: 9, trigger: trigger, scheduler: scheduler)
}
XCTAssertEqual(result.events, expected[0])
}
}
The above uses my TestScheduler

How make Combine's flatMap to complete overall stream?

I have some code like this
func a() -> AnyPublisher<Void, Never> {
Future<Void, Never> { promise in
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
print(1)
promise(.success(()))
}
}
.eraseToAnyPublisher()
}
func b() -> AnyPublisher<Void, Never> {
Future<Void, Never> { promise in
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
print(2)
promise(.success(()))
}
}
.eraseToAnyPublisher()
}
var tempBag = Set<AnyCancellable>()
let subject = CurrentValueSubject<Int, Never>(1)
subject
.flatMap({ _ in a() })
.flatMap({ _ in b() })
.print()
.sink(receiveCompletion: { _ in
tempBag.removeAll()
}, receiveValue: { })
.store(in: &tempBag)
So, I have some uncompletable subject in the root of the stream and some completable publishers in flatMap operator. I want the overall stream to complete when the last flatMap's publisher completes. So, I want the console to look like this:
receive subscription: (FlatMap)
request unlimited
1
2
receive value: (())
receive finished
but actual result is
receive subscription: (FlatMap)
request unlimited
1
2
receive value: (())
How can I achieve this?
The problem you are having is that your Subject (the CurrentValueSubject) never finishes so the entire chain never completes. What you need is a publisher that emits a single value then completes at the top of your sequence, and an intermediate that waits until all the publishers it is tracking complete before finishing itself.
You already have a publisher that does one thing then completes... it's returned by a(). To wait until both a() and b() complete you can use combineLatest since a the publisher it creates won't finish until all the publishers it combines finish. The whole thing looks like:
a()
.combineLatest(b())
.print()
.sink(receiveCompletion: { _ in
tempBag.removeAll()
}, receiveValue: { _ in () })
.store(in: &tempBag)
with the output
receive subscription: (CombineLatest)
request unlimited
1
2
receive value: (((), ()))
receive finished

Memory leak when using `Publishers.Sequence`

I have a function that create collection of Publishers:
func publishers(from text: String) -> [AnyPublisher<SignalToken, Never>] {
let signalTokens: [SignalToken] = translate(from: text)
var delay: Int = 0
let signalPublishers: [AnyPublisher<SignalToken, Never>] = signalTokens.map { token in
let publisher = Just(token)
.delay(for: .milliseconds(delay), scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
delay += token.delay
return publisher
}
return signalPublishers
}
In service class I have to method, one for play():
func play(signal: String) {
anyCancellable = signalTokenSubject.sink(receiveValue: { token in print(token) }
anyCancellable2 = publishers(from: signal)
.publisher
.flatMap { $0 }
.subscribe(on: DispatchQueue.global())
.sink(receiveValue: { [weak self] token in
self?.signalTokenSubject.send(token)
})
}
and one for stop():
func stop() {
anyCancellable?.cancel()
anyCancellable2?.cancel()
}
I've had problem with memory. When collection of publishers is large and I stop() before whole Publishers.Sequence is .finshed memory increase and never release.
Is there a way to completed Publishers.Sequence earlier, before Combine iterate over whole collection?
To reclaim the memory, release the pipelines:
func stop() {
anyCancellable?.cancel()
anyCancellable2?.cancel()
anyCancellable = nil
anyCancellable2 = nil
}
Actually you don't need the cancel calls, because releasing the pipelines does cancel in good order; that is the whole point of AnyCancellable. So you can just say:
func stop() {
anyCancellable = nil
anyCancellable2 = nil
}
Another thing to note is that you are running all your publishers at once. The sequence does not arrive sequentially; the whole sequence is dumped into the flapMap which starts all the publishers publishing simultaneously. Thus cancelling doesn't do you all that much good. You might want to set the maxPublishers: on your flatMap so that backpressure prevents more than some small number of publishers from arriving simultaneously (like for example one at a time).

How can I continue URLSession dataTaskPublisher or another Publisher after error?

I have an app that needs to check a status on a server:
every 30 seconds
whenever the app enters the foreground
I'm doing this by merging two publishers, then calling flatMap the merged publisher's output to trigger the API request.
I have a function that makes an API request and returns a publisher of the result, also including logic to check the response and throw an error depending on its contents.
It seems that once a StatusError.statusUnavailable error is thrown, the statusSubject stops getting updates. How can I change this behavior so the statusSubject continues getting updates after the error? I want the API requests to continue every 30 seconds and when the app is opened, even after there is an error.
I also have a few other points where I'm confused about my current code, indicated by comments, so I'd appreciate any help, explanation, or ideas in those areas too.
Here's my example code:
import Foundation
import SwiftUI
import Combine
struct StatusResponse: Codable {
var response: String?
var error: String?
}
enum StatusError: Error {
case statusUnavailable
}
class Requester {
let statusSubject = CurrentValueSubject<StatusResponse,Error>(StatusResponse(response: nil, error: nil))
private var cancellables: [AnyCancellable] = []
init() {
// Check for updated status every 30 seconds
let timer = Timer
.publish(every: 30,
tolerance: 10,
on: .main,
in: .common,
options: nil)
.autoconnect()
.map { _ in true } // how else should I do this to be able to get these two publisher outputs to match so I can merge them?
// also check status on server when the app comes to the foreground
let foreground = NotificationCenter.default
.publisher(for: UIApplication.willEnterForegroundNotification)
.map { _ in true }
// bring the two publishes together
let timerForegroundCombo = timer.merge(with: foreground)
timerForegroundCombo
// I don't understand why this next line is necessary, but the compiler gives an error if I don't have it
.setFailureType(to: Error.self)
.flatMap { _ in self.apiRequest() }
.subscribe(statusSubject)
.store(in: &cancellables)
}
private func apiRequest() -> AnyPublisher<StatusResponse, Error> {
let url = URL(string: "http://www.example.com/status-endpoint")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
return URLSession.shared.dataTaskPublisher(for: request)
.mapError { $0 as Error }
.map { $0.data }
.decode(type: StatusResponse.self, decoder: JSONDecoder())
.tryMap({ status in
if let error = status.error,
error.contains("status unavailable") {
throw StatusError.statusUnavailable
} else {
return status
}
})
.eraseToAnyPublisher()
}
}
Publishing a failure always ends a subscription. Since you want to continue publishing after an error, you cannot publish your error as a failure. You must instead change your publisher's output type. The standard library provides Result, and that's what you should use.
func makeStatusPublisher() -> AnyPublisher<Result<StatusResponse, Error>, Never> {
let timer = Timer
.publish(every: 30, tolerance: 10, on: .main, in: .common)
.autoconnect()
.map { _ in true } // This is the correct way to merge with the notification publisher.
let notes = NotificationCenter.default
.publisher(for: UIApplication.willEnterForegroundNotification)
.map { _ in true }
return timer.merge(with: notes)
.flatMap({ _ in
statusResponsePublisher()
.map { Result.success($0) }
.catch { Just(Result.failure($0)) }
})
.eraseToAnyPublisher()
}
This publisher emits either .success(response) or .failure(error) periodically, and never completes with a failure.
However, you should ask yourself, what happens if the user switches apps repeatedly? Or what if the API request takes more that 30 seconds to complete? (Or both?) You'll get multiple requests running simultaneously, and the responses will be handled in the order they arrive, which might not be the order in which the requests were sent.
One way to fix this would be to use flatMap(maxPublisher: .max(1)) { ... }, which makes flatMap ignore timer and notification signals while it's got a request outstanding. But it would perhaps be even better for it to start a new request on each signal, and cancel the prior request. Change flatMap to map followed by switchToLatest for that behavior:
func makeStatusPublisher2() -> AnyPublisher<Result<StatusResponse, Error>, Never> {
let timer = Timer
.publish(every: 30, tolerance: 10, on: .main, in: .common)
.autoconnect()
.map { _ in true } // This is the correct way to merge with the notification publisher.
let notes = NotificationCenter.default
.publisher(for: UIApplication.willEnterForegroundNotification)
.map { _ in true }
return timer.merge(with: notes)
.map({ _ in
statusResponsePublisher()
.map { Result<StatusResponse, Error>.success($0) }
.catch { Just(Result<StatusResponse, Error>.failure($0)) }
})
.switchToLatest()
.eraseToAnyPublisher()
}
You can use retry() to get this kind of behaviour or catch it...more info here:
https://www.avanderlee.com/swift/combine-error-handling/

Swift Combine: How can I create a reusable Publishers.Map to connect to multiple upstream Publishers?

I'm trying to wrap my head around Combine, and as I refactor code I'm running into some confusion when I try to re-compose to avoid repeating myself.
In this case, I have a value I want to update any time:
A specific subject changes
The app comes to the foreground
A 3-second refresh timer fires
Since the 3-second refresh timer doesn't publish anything until the first time it fires, I'm assuming I need multiple publishers.
I'm always only using the value from the subject, and ignoring any values sent from the foreground notification and timer.
Here is some example code, where I am processing the value based on only one publisher:
import UIKit
import Combine
class DataStore {
#Published var fillPercent: CGFloat = 0
private var cancellables: [AnyCancellable] = []
// how much the glass can hold, in milliliters
private let glassCapacity: Double = 500
// how full the glass is, in milliliters
private var glassFillLevelSubject = CurrentValueSubject<Double,Never>(250)
// a publisher that fires every three seconds
private let threeSecondTimer = Timer
.publish(every: 3,
on: RunLoop.main,
in: .common)
.autoconnect()
// a publisher that fires every time the app enters the foreground
private let willEnterForegroundPublisher = NotificationCenter.default
.publisher(for: UIApplication.willEnterForegroundNotification)
init() {
// publisher that fires any time the glass level changes or three second timer fires
let glassLevelOrTimerPublisher = Publishers.CombineLatest(glassFillLevelSubject, threeSecondTimer)
// is there shorthand to only return the first item? like .map{ $0 }?
.map { glassFillLevel, timer -> Double in
return glassFillLevel
}
.eraseToAnyPublisher()
// publisher that fires any time the glass level changes or three second timer fires
let glassLevelOrForegroundPublisher = Publishers.CombineLatest(glassFillLevelSubject, willEnterForegroundPublisher)
.map{ glassFillLevel, notification -> Double in
return glassFillLevel
}
.eraseToAnyPublisher()
// how can I define map and everything after it as something, and then subscribe it to the two publishers above?
glassLevelOrTimerPublisher
.map{ fillLevelInMilliliters in
let fillPercent = fillLevelInMilliliters / self.glassCapacity
return CGFloat(fillPercent)
}
.assign(to: \.fillPercent, on: self)
.store(in: &cancellables)
}
}
I think what I want to do here is somehow separate out .map and everything after it, and somehow subscribe that to both publishers above.
I tried this, as a way to isolate everything after .map to make it reusable:
let fillPercentStream = Publishers.Map{ fillLevelInMilliliters in
let fillPercent = fillLevelInMilliliters / self.glassCapacity
return CGFloat(fillPercent)
}
.assign(to: \.fillPercent, on: self)
.store(in: &cancellables)
But that gave me an error that said Missing argument for parameter 'upstream' in call so I tried adding something for that parameter, and ended up with this:
let fillPercentStream = Publishers.Map(upstream: AnyPublisher<Double,Never>, transform: { fillLevelInMilliliters in
let fillPercent = fillLevelInMilliliters / self.glassCapacity
return CGFloat(fillPercent)
})
.assign(to: \.fillPercent, on: self)
.store(in: &cancellables)
Then, I end up in a chain of compiler errors: Unable to infer complex closure return type; add explicit type to disambiguate and it suggests I specify -> CGFloat in .map, which I added, but then it tells me I should change CGFloat to _ and I end up with more errors.
Is this even something I'm supposed to be able to do with Combine? Am I going about this all the wrong way? How can I properly re-use the .map and .assign chain with two different publishers?
I'm somewhat new to Combine and reactive programming in general, so if you have other suggestions to improve how I'm doing everything here please tell me.
One way to handle this is to map your timer publisher output to the subject's value, and also to map your notification publisher output to the subject's value. Then you can merge the subject, the timer, and the notification into a single publisher that publishes the subject's value when the subject's value changes and when the timer fires and when the notification is posted.
import Combine
import Foundation
import UIKit
class DataStore: ObservableObject {
init() {
fractionFilled = CGFloat(fillSubject.value / capacity)
let fillSubject = self.fillSubject // avoid retain cycles in map closures
let timer = Timer.publish(every: 3, on: .main, in: .common)
.autoconnect()
.map { _ in fillSubject.value }
let foreground = NotificationCenter.default
.publisher(for: UIApplication.willEnterForegroundNotification)
.map { _ in fillSubject.value }
let combo = fillSubject.merge(with: timer, foreground)
combo
.map { [capacity] in CGFloat($0 / capacity) }
.sink { [weak self] in self?.fractionFilled = $0 }
.store(in: &tickets)
}
let capacity: Double = 500
#Published var fractionFilled: CGFloat
private let fillSubject = CurrentValueSubject<Double, Never>(250)
private var tickets: [AnyCancellable] = []
}

Resources