Skip to content

[feat]: add runtime observation API #168

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Feb 8, 2023
99 changes: 90 additions & 9 deletions Workflow/Sources/SubtreeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,23 @@ extension WorkflowNode {
/// The current array of side-effects
internal private(set) var sideEffectLifetimes: [AnyHashable: SideEffectLifetime] = [:]

init() {}
private let session: WorkflowSession

private let observer: WorkflowObserver?

init(
session: WorkflowSession,
observer: WorkflowObserver? = nil
) {
self.session = session
self.observer = observer
}

/// Performs an update pass using the given closure.
func render<Rendering>(_ actions: (RenderContext<WorkflowType>) -> Rendering) -> Rendering {
func render<Rendering>(
_ actions: (RenderContext<WorkflowType>) -> Rendering,
workflow: WorkflowType
) -> Rendering {
/// Invalidate the previous action handlers.
for eventPipe in eventPipes {
eventPipe.invalidate()
Expand All @@ -47,7 +60,10 @@ extension WorkflowNode {
let context = Context(
previousSinks: previousSinks,
originalChildWorkflows: childWorkflows,
originalSideEffectLifetimes: sideEffectLifetimes
originalSideEffectLifetimes: sideEffectLifetimes,
workflow: workflow,
session: session,
observer: observer
)

let wrapped = RenderContext.make(implementation: context)
Expand Down Expand Up @@ -134,10 +150,17 @@ extension WorkflowNode.SubtreeManager {
private let originalSideEffectLifetimes: [AnyHashable: SideEffectLifetime]
internal private(set) var usedSideEffectLifetimes: [AnyHashable: SideEffectLifetime]

private let workflow: WorkflowType
private let session: WorkflowSession
private let observer: WorkflowObserver?

internal init(
previousSinks: [ObjectIdentifier: AnyReusableSink],
originalChildWorkflows: [ChildKey: AnyChildWorkflow],
originalSideEffectLifetimes: [AnyHashable: SideEffectLifetime]
originalSideEffectLifetimes: [AnyHashable: SideEffectLifetime],
workflow: WorkflowType,
session: WorkflowSession,
observer: WorkflowObserver?
) {
self.eventPipes = []

Expand All @@ -148,13 +171,24 @@ extension WorkflowNode.SubtreeManager {

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

self.workflow = workflow
self.session = session
self.observer = observer
}

func render<Child, Action>(workflow: Child, key: String, outputMap: @escaping (Child.Output) -> Action) -> Child.Rendering where Child: Workflow, Action: WorkflowAction, WorkflowType == Action.WorkflowType {
func render<Child, Action>(
workflow: Child,
key: String,
outputMap: @escaping (Child.Output) -> Action
) -> Child.Rendering
where Child: Workflow,
Action: WorkflowAction,
WorkflowType == Action.WorkflowType {
/// A unique key used to identify this child workflow
let childKey = ChildKey(childType: Child.self, key: key)

/// If the key already exists in `used`, than a workflow of the same type has been rendered multiple times
/// If the key already exists in `used`, then a workflow of the same type has been rendered multiple times
/// during this render pass with the same key. This is not allowed.
guard usedChildWorkflows[childKey] == nil else {
fatalError("Child workflows of the same type must be given unique keys. Duplicate workflows of type \(Child.self) were encountered with the key \"\(key)\" in \(WorkflowType.self)")
Expand Down Expand Up @@ -185,7 +219,10 @@ extension WorkflowNode.SubtreeManager {
child = ChildWorkflow<Child>(
workflow: workflow,
outputMap: { AnyWorkflowAction(outputMap($0)) },
eventPipe: eventPipe
eventPipe: eventPipe,
key: key,
parentSession: session,
observer: observer
)
}

Expand All @@ -198,6 +235,21 @@ extension WorkflowNode.SubtreeManager {
func makeSink<Action>(of actionType: Action.Type) -> Sink<Action> where Action: WorkflowAction, WorkflowType == Action.WorkflowType {
let reusableSink = sinkStore.findOrCreate(actionType: Action.self)

// Update the observation info for use when an action is sent
// through the sink we vend to the 'outside world'. This data
// is stored on the `ReusableSink` instance so that any relevant
// references are decremented once the backing node in the tree
// is removed.
reusableSink.observerInfo = observer.map {
ReusableSink.ObserverInfo(
workflow: workflow,
observer: $0,
session: session
)
}

// Use a weak capture to prevent event propagation once the
// node backing this sink is torn down.
let sink = Sink<Action> { [weak reusableSink] action in
WorkflowLogger.logSinkEvent(ref: SignpostRef(), action: action)

Expand Down Expand Up @@ -271,9 +323,26 @@ extension WorkflowNode.SubtreeManager {
}

fileprivate final class ReusableSink<Action: WorkflowAction>: AnyReusableSink where Action.WorkflowType == WorkflowType {
/// Information to support runtime observation when actions are handled
struct ObserverInfo {
var workflow: WorkflowType
var observer: WorkflowObserver
var session: WorkflowSession
}

var observerInfo: ObserverInfo?

func handle(action: Action) {
let output = Output.update(AnyWorkflowAction(action), source: .external)

if let observerInfo = observerInfo {
observerInfo.observer.workflowDidReceiveAction(
action,
workflow: observerInfo.workflow,
session: observerInfo.session
)
}

if case .pending = eventPipe.validationState {
// Workflow is currently processing an `event`.
// Scheduling it to be processed after.
Expand Down Expand Up @@ -389,9 +458,21 @@ extension WorkflowNode.SubtreeManager {
private let node: WorkflowNode<W>
private var outputMap: (W.Output) -> AnyWorkflowAction<WorkflowType>

init(workflow: W, outputMap: @escaping (W.Output) -> AnyWorkflowAction<WorkflowType>, eventPipe: EventPipe) {
init(
workflow: W,
outputMap: @escaping (W.Output) -> AnyWorkflowAction<WorkflowType>,
eventPipe: EventPipe,
key: String,
parentSession: WorkflowSession?,
observer: WorkflowObserver?
) {
self.outputMap = outputMap
self.node = WorkflowNode<W>(workflow: workflow)
self.node = WorkflowNode<W>(
workflow: workflow,
key: key,
parentSession: parentSession,
observer: observer
)

super.init(eventPipe: eventPipe)

Expand Down
6 changes: 6 additions & 0 deletions Workflow/Sources/WorkflowAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ public protocol WorkflowAction<WorkflowType> {
public struct AnyWorkflowAction<WorkflowType: Workflow>: WorkflowAction {
private let _apply: (inout WorkflowType.State) -> WorkflowType.Output?

/// Underlying type-erased `WorkflowAction` value, if it exists. Will be nil if the
/// action is defined by a closure. Primarily used for testing purposes.
let _wrappedValue: (any WorkflowAction<WorkflowType>)?

/// Creates a type-erased workflow action that wraps the given instance.
///
/// - Parameter base: A workflow action to wrap.
Expand All @@ -46,13 +50,15 @@ public struct AnyWorkflowAction<WorkflowType: Workflow>: WorkflowAction {
return
}
self._apply = { return base.apply(toState: &$0) }
self._wrappedValue = base
}

/// Creates a type-erased workflow action with the given `apply` implementation.
///
/// - Parameter apply: the apply function for the resulting action.
public init(_ apply: @escaping (inout WorkflowType.State) -> WorkflowType.Output?) {
self._apply = apply
self._wrappedValue = nil
}

public func apply(toState state: inout WorkflowType.State) -> WorkflowType.Output? {
Expand Down
21 changes: 18 additions & 3 deletions Workflow/Sources/WorkflowHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ public final class WorkflowHost<WorkflowType: Workflow> {

private let (outputEvent, outputEventObserver) = Signal<WorkflowType.Output, Never>.pipe()

private let rootNode: WorkflowNode<WorkflowType>
// @testable
internal let rootNode: WorkflowNode<WorkflowType>

private let mutableRendering: MutableProperty<WorkflowType.Rendering>

Expand All @@ -47,12 +48,26 @@ public final class WorkflowHost<WorkflowType: Workflow> {
/// Initializes a new host with the given workflow at the root.
///
/// - Parameter workflow: The root workflow in the hierarchy
/// - Parameter observers: An optional array of `WorkflowObservers` that will allow runtime introspection for this `WorkflowHost`
/// - Parameter debugger: An optional debugger. If provided, the host will notify the debugger of updates
/// to the workflow hierarchy as state transitions occur.
public init(workflow: WorkflowType, debugger: WorkflowDebugger? = nil) {
public init(
workflow: WorkflowType,
observers: [WorkflowObserver] = [],
debugger: WorkflowDebugger? = nil
) {
self.debugger = debugger

self.rootNode = WorkflowNode(workflow: workflow)
let observers = WorkflowObservation
.sharedObserversInterceptor
.workflowObservers(for: observers)
.chained()

self.rootNode = WorkflowNode(
workflow: workflow,
parentSession: nil,
observer: observers
)

self.mutableRendering = MutableProperty(rootNode.render(isRootNode: true))
self.rendering = Property(mutableRendering)
Expand Down
73 changes: 67 additions & 6 deletions Workflow/Sources/WorkflowNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,48 @@ final class WorkflowNode<WorkflowType: Workflow> {
private var state: WorkflowType.State

/// Holds the current workflow.
private var workflow: WorkflowType
private(set) var workflow: WorkflowType

/// An optional `WorkflowObserver` instance
let observer: WorkflowObserver?

var onOutput: ((Output) -> Void)?

/// Manages the children of this workflow, including diffs during/after render passes.
private let subtreeManager = SubtreeManager()
private let subtreeManager: SubtreeManager

/// 'Session' metadata associated with this node
let session: WorkflowSession

init(workflow: WorkflowType) {
init(
workflow: WorkflowType,
key: String = "",
parentSession: WorkflowSession? = nil,
observer: WorkflowObserver? = nil
) {
/// Get the initial state
self.workflow = workflow
self.observer = observer
self.session = WorkflowSession(
workflow: workflow,
renderKey: key,
parent: parentSession
)
self.subtreeManager = SubtreeManager(
session: session,
observer: observer
)

self.observer?.sessionDidBegin(session)

self.state = workflow.makeInitialState()

self.observer?.workflowDidMakeInitialState(
workflow,
initialState: state,
session: session
)

WorkflowLogger.logWorkflowStarted(ref: self)

subtreeManager.onUpdate = { [weak self] output in
Expand All @@ -40,6 +70,7 @@ final class WorkflowNode<WorkflowType: Workflow> {
}

deinit {
observer?.sessionDidEnd(session)
WorkflowLogger.logWorkflowFinished(ref: self)
}

Expand All @@ -49,6 +80,13 @@ final class WorkflowNode<WorkflowType: Workflow> {

switch subtreeOutput {
case .update(let event, let source):
let actionObserverCompletion = observer?.workflowWillApplyAction(
event,
workflow: workflow,
state: state,
session: session
)

/// Apply the update to the current state
let outputEvent = event.apply(toState: &state)

Expand All @@ -61,6 +99,8 @@ final class WorkflowNode<WorkflowType: Workflow> {
)
)

actionObserverCompletion?(state, outputEvent)

case .childDidUpdate(let debugInfo):
output = Output(
outputEvent: nil,
Expand All @@ -82,17 +122,29 @@ final class WorkflowNode<WorkflowType: Workflow> {
func render(isRootNode: Bool = false) -> WorkflowType.Rendering {
WorkflowLogger.logWorkflowStartedRendering(ref: self, isRootNode: isRootNode)

let renderObserverCompletion = observer?.workflowWillRender(
workflow,
state: state,
session: session
)

let rendering: WorkflowType.Rendering

defer {
renderObserverCompletion?(rendering)

WorkflowLogger.logWorkflowFinishedRendering(ref: self, isRootNode: isRootNode)
}

return subtreeManager.render { context in
rendering = subtreeManager.render({ context in
workflow
.render(
state: state,
context: context
)
}
}, workflow: workflow)

return rendering
}

func enableEvents() {
Expand All @@ -101,8 +153,17 @@ final class WorkflowNode<WorkflowType: Workflow> {

/// Updates the workflow.
func update(workflow: WorkflowType) {
workflow.workflowDidChange(from: self.workflow, state: &state)
let oldWorkflow = self.workflow

workflow.workflowDidChange(from: oldWorkflow, state: &state)
self.workflow = workflow

observer?.workflowDidChange(
from: oldWorkflow,
to: workflow,
state: state,
session: session
)
}

func makeDebugSnapshot() -> WorkflowHierarchyDebugSnapshot {
Expand Down
Loading