Skip to content

Commit

Permalink
Merge pull request #1174 from square/dhaval/sideEffect
Browse files Browse the repository at this point in the history
Introduce SideEffect #1021
  • Loading branch information
dhavalshreyas authored Jun 11, 2020
2 parents 967b69d + 0fa0ade commit b852e0d
Show file tree
Hide file tree
Showing 9 changed files with 351 additions and 30 deletions.
5 changes: 5 additions & 0 deletions swift/Workflow/Sources/Debugging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ extension WorkflowUpdateDebugInfo {
public indirect enum Source: Equatable {
case external
case worker
case sideEffect
case subtree(WorkflowUpdateDebugInfo)
}
}
Expand All @@ -96,6 +97,8 @@ extension WorkflowUpdateDebugInfo.Source: Codable {
case let .subtree(debugInfo):
try container.encode("subtree", forKey: .type)
try container.encode(debugInfo, forKey: .debugInfo)
case .sideEffect:
try container.encode("side-effect", forKey: .type)
}
}

Expand All @@ -114,6 +117,8 @@ extension WorkflowUpdateDebugInfo.Source: Codable {
case "subtree":
let debugInfo = try container.decode(WorkflowUpdateDebugInfo.self, forKey: .debugInfo)
self = .subtree(debugInfo)
case "side-effect":
self = .sideEffect
default:
throw MalformedDataError()
}
Expand Down
43 changes: 43 additions & 0 deletions swift/Workflow/Sources/Lifetime.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright 2020 Square Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import Foundation

/// Represents the lifetime of an object.
///
/// Once ended, the `onEnded` closure is called.
public final class Lifetime {
/// Hook to clean-up after end of `lifetime`.
public func onEnded(_ action: @escaping () -> Void) {
assert(!hasEnded, "Lifetime used after being ended.")
onEndedActions.append(action)
}

public private(set) var hasEnded: Bool = false
private var onEndedActions: [() -> Void] = []

deinit {
end()
}

func end() {
guard !hasEnded else {
return
}
hasEnded = true
onEndedActions.forEach { $0() }
}
}
25 changes: 25 additions & 0 deletions swift/Workflow/Sources/RenderContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,24 @@ public class RenderContext<WorkflowType: Workflow>: RenderContextType {
fatalError()
}

/// Execute a side-effect action.
///
/// Note that it is a programmer error to run two side-effects with the same `key`
/// during the same render pass.
///
/// `action` will be executed the first time a side-effect is run with a given `key`.
/// `runSideEffect` calls with a given `key` on subsequent renders are ignored.
///
/// If after a render pass, a side-effect with a `key` that was previously used is not used,
/// it's lifetime ends and the `Lifetime` object's `onEnded` closure will be called.
///
/// - Parameters:
/// - key: represents the block of work that needs to be executed.
/// - action: a block of work that will be executed.
public func runSideEffect(key: AnyHashable, action: (Lifetime) -> Void) {
fatalError()
}

final func invalidate() {
isValid = false
}
Expand Down Expand Up @@ -103,6 +121,11 @@ public class RenderContext<WorkflowType: Workflow>: RenderContextType {
return implementation.makeSink(of: actionType)
}

override func runSideEffect(key: AnyHashable, action: (_ lifetime: Lifetime) -> Void) {
assertStillValid()
implementation.runSideEffect(key: key, action: action)
}

override func awaitResult<W, Action>(for worker: W, outputMap: @escaping (W.Output) -> Action) where W: Worker, Action: WorkflowAction, WorkflowType == Action.WorkflowType {
assertStillValid()
implementation.awaitResult(for: worker, outputMap: outputMap)
Expand All @@ -122,6 +145,8 @@ internal protocol RenderContextType: AnyObject {
func makeSink<Action>(of actionType: Action.Type) -> Sink<Action> where Action: WorkflowAction, Action.WorkflowType == WorkflowType

func awaitResult<W, Action>(for worker: W, outputMap: @escaping (W.Output) -> Action) where W: Worker, Action: WorkflowAction, Action.WorkflowType == WorkflowType

func runSideEffect(key: AnyHashable, action: (_ lifetime: Lifetime) -> Void)
}

extension RenderContext {
Expand Down
55 changes: 49 additions & 6 deletions swift/Workflow/Sources/SubtreeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ extension WorkflowNode {
/// The current array of workers
internal private(set) var childWorkers: [AnyChildWorker] = []

/// The current array of side-effects
internal private(set) var sideEffectLifetimes: [AnyHashable: SideEffectLifetime] = [:]

init() {}

/// Performs an update pass using the given closure.
Expand All @@ -48,7 +51,8 @@ extension WorkflowNode {
let context = Context(
previousSinks: previousSinks,
originalChildWorkflows: childWorkflows,
originalChildWorkers: childWorkers
originalChildWorkers: childWorkers,
originalSideEffectLifetimes: sideEffectLifetimes
)

let wrapped = RenderContext.make(implementation: context)
Expand All @@ -63,6 +67,7 @@ extension WorkflowNode {
/// as a result of this call to `render`.
childWorkflows = context.usedChildWorkflows
childWorkers = context.usedChildWorkers
sideEffectLifetimes = context.usedSideEffectLifetimes

/// Captured the reusable sinks from this render pass.
previousSinks = context.sinkStore.usedSinks
Expand All @@ -85,7 +90,7 @@ extension WorkflowNode {
let queuedEvents = eventPipes.compactMap { pipe in
pipe.pendingOutput()
}
if queuedEvents.count > 0 {
if !queuedEvents.isEmpty {
handle(output: queuedEvents[0])
return
}
Expand Down Expand Up @@ -144,7 +149,15 @@ extension WorkflowNode.SubtreeManager {
private let originalChildWorkers: [AnyChildWorker]
internal private(set) var usedChildWorkers: [AnyChildWorker]

internal init(previousSinks: [ObjectIdentifier: AnyReusableSink], originalChildWorkflows: [ChildKey: AnyChildWorkflow], originalChildWorkers: [AnyChildWorker]) {
private let originalSideEffectLifetimes: [AnyHashable: SideEffectLifetime]
internal private(set) var usedSideEffectLifetimes: [AnyHashable: SideEffectLifetime]

internal init(
previousSinks: [ObjectIdentifier: AnyReusableSink],
originalChildWorkflows: [ChildKey: AnyChildWorkflow],
originalChildWorkers: [AnyChildWorker],
originalSideEffectLifetimes: [AnyHashable: SideEffectLifetime]
) {
self.eventPipes = []

self.sinkStore = SinkStore(previousSinks: previousSinks)
Expand All @@ -154,6 +167,9 @@ extension WorkflowNode.SubtreeManager {

self.originalChildWorkers = originalChildWorkers
self.usedChildWorkers = []

self.originalSideEffectLifetimes = originalSideEffectLifetimes
self.usedSideEffectLifetimes = [:]
}

func render<Child, Action>(workflow: Child, key: String, outputMap: @escaping (Child.Output) -> Action) -> Child.Rendering where Child: Workflow, Action: WorkflowAction, WorkflowType == Action.WorkflowType {
Expand Down Expand Up @@ -230,6 +246,16 @@ extension WorkflowNode.SubtreeManager {
usedChildWorkers.append(newChildWorker)
}
}

func runSideEffect(key: AnyHashable, action: (Lifetime) -> Void) {
if let existingSideEffect = originalSideEffectLifetimes[key] {
usedSideEffectLifetimes[key] = existingSideEffect
} else {
let sideEffectLifetime = SideEffectLifetime()
action(sideEffectLifetime.lifetime)
usedSideEffectLifetimes[key] = sideEffectLifetime
}
}
}
}

Expand Down Expand Up @@ -403,7 +429,7 @@ extension WorkflowNode.SubtreeManager {

private var outputMap: (W.Output) -> AnyWorkflowAction<WorkflowType>

private let (lifetime, token) = Lifetime.make()
private let (lifetime, token) = ReactiveSwift.Lifetime.make()

init(worker: W, outputMap: @escaping (W.Output) -> AnyWorkflowAction<WorkflowType>, eventPipe: EventPipe) {
self.worker = worker
Expand All @@ -419,7 +445,7 @@ extension WorkflowNode.SubtreeManager {
.observe(on: QueueScheduler.workflowExecution)
.start { [weak self] event in
switch event {
case .value(let output):
case let .value(output):
WorkflowLogger.logWorkerOutput(ref: signpostRef, workerType: W.self)

self?.handle(output: output)
Expand Down Expand Up @@ -472,7 +498,7 @@ extension WorkflowNode.SubtreeManager {
private let node: WorkflowNode<W>
private var outputMap: (W.Output) -> AnyWorkflowAction<WorkflowType>

private let (lifetime, token) = Lifetime.make()
private let (lifetime, token) = ReactiveSwift.Lifetime.make()

init(workflow: W, outputMap: @escaping (W.Output) -> AnyWorkflowAction<WorkflowType>, eventPipe: EventPipe) {
self.outputMap = outputMap
Expand Down Expand Up @@ -519,3 +545,20 @@ extension WorkflowNode.SubtreeManager {
}
}
}

// MARK: - Side Effects

extension WorkflowNode.SubtreeManager {
internal class SideEffectLifetime {
fileprivate let lifetime: Lifetime

fileprivate init() {
self.lifetime = Lifetime()
}

deinit {
// Explicitly end the lifetime in case someone retained it from outside
lifetime.end()
}
}
}
126 changes: 116 additions & 10 deletions swift/Workflow/Tests/SubtreeManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -263,11 +263,12 @@ final class SubtreeManagerTests: XCTestCase {

let isSubscribing = manager.render { context -> Bool in
SubscribingWorkflow(
signal: signal)
.render(
state: SubscribingWorkflow.State(),
context: context
)
signal: signal
)
.render(
state: SubscribingWorkflow.State(),
context: context
)
}
manager.enableEvents()

Expand All @@ -281,11 +282,12 @@ final class SubtreeManagerTests: XCTestCase {

let isStillSubscribing = manager.render { context -> Bool in
SubscribingWorkflow(
signal: nil)
.render(
state: SubscribingWorkflow.State(),
context: context
)
signal: nil
)
.render(
state: SubscribingWorkflow.State(),
context: context
)
}
manager.enableEvents()

Expand All @@ -294,6 +296,110 @@ final class SubtreeManagerTests: XCTestCase {
observer.send(value: ())
wait(for: [notEmittedExpectation], timeout: 1.0)
}

// MARK: - SideEffect

func test_maintainsSideEffectLifetimeBetweenRenderPasses() {
let manager = WorkflowNode<ParentWorkflow>.SubtreeManager()
XCTAssertTrue(manager.sideEffectLifetimes.isEmpty)

_ = manager.render { context -> TestViewModel in
context.runSideEffect(key: "helloWorld") { _ in }
return context.render(
workflow: TestWorkflow(),
key: "",
outputMap: { _ in AnyWorkflowAction.noAction }
)
}

XCTAssertEqual(manager.sideEffectLifetimes.count, 1)
let sideEffectKey = manager.sideEffectLifetimes.values.first!

_ = manager.render { context -> TestViewModel in
context.runSideEffect(key: "helloWorld") { _ in
XCTFail("Unexpected SideEffect execution")
}
return context.render(
workflow: TestWorkflow(),
key: "",
outputMap: { _ in AnyWorkflowAction.noAction }
)
}

XCTAssertEqual(manager.sideEffectLifetimes.count, 1)
XCTAssertTrue(manager.sideEffectLifetimes.values.first === sideEffectKey)
}

func test_endsUnusedSideEffectLifetimeAfterRenderPasses() {
let manager = WorkflowNode<ParentWorkflow>.SubtreeManager()
XCTAssertTrue(manager.sideEffectLifetimes.isEmpty)

let lifetimeEndedExpectation = expectation(description: "Lifetime Ended Expectations")
_ = manager.render { context -> TestViewModel in
context.runSideEffect(key: "helloWorld") { lifetime in
lifetime.onEnded {
// Capturing `lifetime` to make sure a retain-cycle will still trigger the `onEnded` block
print("\(lifetime)")
lifetimeEndedExpectation.fulfill()
}
}
return context.render(
workflow: TestWorkflow(),
key: "",
outputMap: { _ in AnyWorkflowAction.noAction }
)
}

XCTAssertEqual(manager.sideEffectLifetimes.count, 1)

_ = manager.render { context -> TestViewModel in
context.render(
workflow: TestWorkflow(),
key: "",
outputMap: { _ in AnyWorkflowAction.noAction }
)
}

XCTAssertEqual(manager.sideEffectLifetimes.count, 0)
wait(for: [lifetimeEndedExpectation], timeout: 1)
}

func test_verifySideEffectsWithDifferentKeysAreExecuted() {
let manager = WorkflowNode<ParentWorkflow>.SubtreeManager()
XCTAssertTrue(manager.sideEffectLifetimes.isEmpty)

let firstSideEffectExecutedExpectation = expectation(description: "FirstSideEffect")
_ = manager.render { context -> TestViewModel in
context.runSideEffect(key: "key-1") { _ in
firstSideEffectExecutedExpectation.fulfill()
}
return context.render(
workflow: TestWorkflow(),
key: "",
outputMap: { _ in AnyWorkflowAction.noAction }
)
}

wait(for: [firstSideEffectExecutedExpectation], timeout: 1)
XCTAssertEqual(manager.sideEffectLifetimes.count, 1)
XCTAssertEqual(manager.sideEffectLifetimes.keys.first, "key-1")

let secondSideEffectExecutedExpectation = expectation(description: "SecondSideEffect")
_ = manager.render { context -> TestViewModel in
context.runSideEffect(key: "key-2") { _ in
secondSideEffectExecutedExpectation.fulfill()
}
return context.render(
workflow: TestWorkflow(),
key: "",
outputMap: { _ in AnyWorkflowAction.noAction }
)
}

wait(for: [secondSideEffectExecutedExpectation], timeout: 1)
XCTAssertEqual(manager.sideEffectLifetimes.count, 1)
XCTAssertEqual(manager.sideEffectLifetimes.keys.first, "key-2")
}
}

private struct TestViewModel {
Expand Down
Loading

0 comments on commit b852e0d

Please sign in to comment.