0

I have the following scenario - I am using ReactiveSwift's Action to trigger a network request in my app. This network request is potentially expensive due to the processing that is done on it's response. So, when a caller tries to apply the Action I would like to do the following:

  • Determine if the Action is already in progress
    • If it is, return a SignalProducer that observes the results of the in progress Action
    • If it is not, return a SignalProducer that will apply the action when started

Ideally the solution would be thread safe, as callers may try to apply the Action from different threads.

Now I've managed to cobble together something that works using examples of caching in ReactiveSwift, but I'm almost certain I'm doing something wrong particularly in how I'm having to reset my MutableProperty to nil when the Action completes. Note that I'm also using static variables to ensure my multiple instances of the UseCase can't bypass my intended behaviour. Also, my example signals Never output but in the real world they may:

class UseCase {
  private static let sharedAction = Action<Void, Never, AnyError> {
    return SignalProducer.empty.delay(10, on: QueueScheduler.main).on(completed: {
      print("Done")
      UseCase.sharedProducer.value = nil
    })
  }
  private static let sharedProducer = MutableProperty<SignalProducer<Never, AnyError>?>(nil)

  func sync() -> SignalProducer<Never, AnyError> {
    let result = UseCase.sharedProducer.modify { value -> Result<SignalProducer<Never, AnyError>, NoError> in
        if let inProgress = value {
          print("Using in progress")
          return Result(value: inProgress)
        } else {
          print("Starting new")
          let producer = UseCase.sharedAction.apply().flatMapError { error -> SignalProducer<Never, AnyError> in
              switch error {
              case .disabled:                   return SignalProducer.empty
              case .producerFailed(let error):  return SignalProducer(error: error)
              }
            }.replayLazily(upTo: 1)

          value = producer
          return Result(value: producer)
        }
    }

    guard let producer = result.value else {
      fatalError("Unexpectedly found nil producer")
    }

    return producer
  }
}
Arkcann
  • 618
  • 7
  • 15
  • Which object is expected to handle the errors generated from the `sharedAction`? Is it the `UseCase` instance, the caller of `sync`, or some other object entirely? – ABeard89 May 28 '18 at 08:05
  • Another point is that `SignalProducer` (especially tied to an `Action`) doesn't behave the way you think it does. Whatever receives the `SignalProducer` is expected to start it, creating a potentially separate stream of incoming values. Because of this, every time you start the same `SignalProducer`, you start a new unit of work that generates a new stream of values. `SignalProducer`s do not share values to multiple observers. – ABeard89 May 28 '18 at 08:12
  • `Signal`s on the other hand, do share results to multiple observers, because observing a `Signal` has no effect on it. Observing ("starting") a `SignalProducer` begins a new stream and forwards the events to a single observer. – ABeard89 May 28 '18 at 08:14
  • What I often do is forward values from a `SignalProducer` to a `MutableProperty`, which can forward its own values to multiple observers. The only trouble here is that I don't know where you intend to handle errors, so its hard to write an example for your use case. – ABeard89 May 28 '18 at 08:17
  • @Abeard89 thank you for the response - for error handling I'm open to suggestions, in practice these errors will often be ignored or just logged as they are tied to background processes. In regards `SignalProducer`, I am aware that it normally creates a separate stream of values when started. However, my usage of `replayLazily` should prevent this and allow for multiple observers to share the same stream, correct? I'd be interested to see your solution using a `SignalProducer` and a `MutableProperty`. – Arkcann May 29 '18 at 14:02
  • I'm unfamiliar with `replayLazily`. The more I read the documentation for it, the more I'm getting confused. However, the documentation does say to use it only if you absolutely need it for caching. It also says to consider using `Property`s instead. Even if we can use it, I think we're better off without it. – ABeard89 May 30 '18 at 03:46
  • Take a look at my answer. Sorry if it isn't exactly what you're looking for, but it's what I would do. – ABeard89 May 30 '18 at 04:16

1 Answers1

1

This also might a bit lengthy, but it should at least be a bit easier to follow. Feel free to ask any questions.

NOTE: I made this object start processing on its own, rather than return a SignalProducer that the caller would start. Instead, I added a read-only property that listeners can observe without starting the processing.

I try to make my observers as passive as possible, thus making them more "reactive" than "proactive". This pattern should suit your needs, even though it's a bit different.

I tried to make this example include:

  1. Shared results from a single unit of work.
  2. Error handling.
  3. Best practices for signal retention.
  4. General explanations in comments, since good tutorials are very hard to find.
    • Sorry if I'm explaining things you already know. I wasn't sure how much to assume you already know.
  5. Mocked processing delay (remove in production code).

It's far from perfect, but should provide a solid pattern you can modify and expand.

struct MyStruct {}

final class MyClass {
    // MARK: Shared Singleton
    static let shared = MyClass()

    // MARK: Initialization
    private init() {}

    // MARK: Public Stuff
    @discardableResult
    func getValue() -> Signal<MyStruct, NoError> {

        if !self.isGettingValue {
            print("Get value")
            self.start()
        } else {
            print("Already getting value.")
        }

        return self.latestValue
            .signal
            .skipNil()
    }
    var latestValue: Property<MyStruct?> {
        // By using a read-only property, the listener can:
        // 1. Choose to take/ignore the previous value.
        // 2. Choose to listen via Signal, SignalProducer, or binding operator '<~'
        return Property(self.latestValueProperty)
    }

    // MARK: Private Stuff
    private var latestValueProperty = MutableProperty<MyStruct?>(nil)

    private var isGettingValue = false {
        didSet { print("isGettingValue: changed from '\(oldValue)' to '\(self.isGettingValue)'") }
    }

    private func start() {
        // Binding with `<~` automatically starts the SignalProducer with the binding target (our property) as its single listener.
        self.latestValueProperty <~ self.newValueProducer()

            // For testing, delay signal to mock processing time.
            // TODO: Remove in actual implementation.
            .delay(5, on: QueueScheduler.main)

            // If `self` were not a Singleton, this would be very important.
            // Best practice says that you should hold on to signals and producers only as long as you need them.
            .take(duringLifetimeOf: self)

            // In accordance with best practices, take only as many as you need.
            .take(first: 1)

            // Track status.
            .on(
                starting: { [unowned self] in
                    self.isGettingValue = true
                },
                event: { [unowned self] event in
                    switch event {
                    case .completed, .interrupted:
                        self.isGettingValue = false
                    default:
                        break
                    }
                }
            )
    }

    private func newValueProducer() -> SignalProducer<MyStruct?, NoError> {
        return SignalProducer<MyStruct?, AnyError> { observer, lifetime in

            // Get Struct with possible error
            let val = MyStruct()

            // Send and complete the signal.
            observer.send(value: val)
            observer.sendCompleted()

            }

            // Don't hold on to errors longer than you need to.
            // I like to handle them as close to the source as I can.
            .flatMapError { [unowned self] error in
                // Deal with error
                self.handle(error: error)

                // Transform error type from `AnyError` to `NoError`, to signify that the error has been handled.
                // `.empty` returns a producer that sends no values and completes immediately.
                // If you wanted to, you could return a producer that sends a default or alternative value.
                return SignalProducer<MyStruct?, NoError>.empty
        }
    }

    private func handle(error: AnyError) {

    }
}

TEST

// Test 1: Start processing and observe the results.
MyClass.shared
    .getValue()
    .take(first: 1)
    .observeValues { _ in
        print("Test 1 value received.")
}

// Test 2: Attempt to start (attempt ignored) and observe the same result from Test 1.
MyClass.shared
    .getValue()
    .take(first: 1)
    .observeValues { _ in
        print("Test 2 value received.")
}

// Test 3: Observe Value from Test 1 without attempting to restart.
MyClass.shared
    .latestValue
    .signal
    .skipNil()
    .take(first: 1)
    .observeValues { _ in
        print("Test 3 value received.")
}

// Test 4: Attempt to restart processing and discard signal
MyClass.shared.getValue()

Output:

Get value
isGettingValue: changed from 'false' to 'true'
Already getting value.
Already getting value.

(5 seconds later)

Test 1 value received.
Test 2 value received.
Test 3 value received.
isGettingValue: changed from 'true' to 'false'
ABeard89
  • 911
  • 9
  • 17
  • 1
    How might this work if some of the calls to `getValue` are coming from different threads? It seems like that may introduce race conditions due to the usage of `isGettingValue`. Even if that is the case, I like the amount of detail you put into this answer and that you showed the output of the example. In practice threading isn't really an issue for my use case so your example would be sufficient - and I like that it's possible for observers to observe the latest values without initiating a new request. Thank you for taking the time on this! – Arkcann Jun 01 '18 at 17:46
  • Definitely would cause problems from different threads. You could modify this to use its own thread and control how `isGettingValue` gets modified. Or you could change it to use an `Action` instead and forward its values to the `MutableProperty`. – ABeard89 Jun 02 '18 at 02:11
  • This was mainly a proof of concept to use as a base point though. – ABeard89 Jun 02 '18 at 02:13
  • 1
    The good thing about `Action`s is that they can be implicitly enabled and disabled with a `Property`, and you could also tie that to its own `isExecuting` `Property` to help with threading. I thought about that, but thought the code was a little too long for this example. – ABeard89 Jun 02 '18 at 02:18