[Pitch] Transactional observation of values

After much anticipation and a lot of footwork, I am really excited to present something I have been working on for a bit now. A transactional observation mechanism to provide an AsyncSequence from approachable composition of @Observable data sources. Here is the pitch as a pull request to Swift Evolution.

Introduction

Observation was introduced to add the ability to observe changes in graphs of objects. The initial tools for observation afforded seamless integration into SwiftUI, however aiding SwiftUI is not the only intent of the module - it is more general than that. This proposal describes a new safe, ergonomic and composable way to observe changes to models using an AsyncSequence, starting transactions at the first willSet and then emitting a value upon that transaction end at the first point of consistency by interoperating with Swift Concurrency.

Motivation

Observation was designed to allow future support for providing an AsyncSequence of values, as described in the initial Observability proposal. This follow-up proposal offers tools for enabling asynchronous sequences of values, allowing non-SwiftUI systems to have the same level of "just-the-right-amount-of-magic" as when using SwiftUI.

Numerous frameworks in the Darwin SDKs provide APIs for accessing an
AsyncSequence of values emitted from changes to a property on a given model type. For example, DockKit provides trackingStates and Group Activities provides localParticipantStates. These are much like other APIs that provide AsyncSequence from a model type; they hand crafted to provide events from when that object changes. These manual implementations are not trivial and require careful book-keeping to get right. In addition, library and application code faces the same burden to use this pattern for observing changes. Each of these uses would benefit from having a centralized and easy mechanism to implement this kind of sequence.

Observation was built to let developers avoid the complexity inherent when making sure the UI is updated upon value changes. For developers using SwiftUI and the @Observable macro to mark their types, this principle is already realized; directly using values over time should mirror this ease of use, providing the same level of power and flexibility. That model of tracking changes by a graph allows for perhaps the most compelling part of Observation; it can track changes by utilizing naturally written Swift code that is written just like the logic of other plain functions. In practice that means that any solution will also follow that same concept even for disjoint graphs that do not share connections. The solution will allow for iterating changed values for applications that do not use UI as seamlessly as those that do.

Proposed solution

This proposal adds a straightforward new tool: a closure-initialized Observed type that acts as a sequence of closure-returned values, emitting new values when something within that closure changes.

This new type makes it easy to write asynchronous sequences to track changes but also ensures that access is safe with respect to concurrency.

The simple Person type declared here will be used for examples in the
remainder of this proposal:

@Observable
final class Person {
  var firstName: String
  var lastName: String
 
  var name: String { firstName + " " + lastName } 

  init(firstName: String, lastName: String) { 
    self.firstName = firstName
    self.lastName = lastName 
  }
}

Creating an Observed asynchronous sequence is straightforward. This example creates an asynchronous sequence that yields a value every time the composed name property is updated:

let names = Observed { person.name }

However if the example was more complex and the Person type in the previous example had a var pet: Pet? property which was also @Observable then the closure can be written with a more complex expression.

@Observable
final class Pet {
  var name: String
  init(name: String } { self.name = name }
}

@Observable
final class Person {
  var firstName: String
  var lastName: String
  var pet: Pet?
 
  var name: String { firstName + " " + lastName } 

  init(firstName: String, lastName: String) { 
    self.firstName = firstName
    self.lastName = lastName 
  }
}
...
let greetings = Observed {
  if let pet = person.pet {
    return "Hello \(person.name) and \(pet.name)"
  } else {
    return "Hello \(person.name)"
  }
}

In that example it would track both the assignment of a new pet and then consequently that pet's name.

Detailed design

There a few behaviors that are prerequisites to understanding the requirements of the actual design. These two key behaviors are how the model handles tearing and how the model handles sharing.

Tearing is where a value that is expected to be assigned as a singular transactional operation can potentially be observed in an intermediate and inconsistent state. The example Person type shows this when a firstName is set and then the lastName is set. If the observation was triggered just on the trailing edge (the didSet operation) then an assignment to both properties would garner an event for both properties and potentially get an inconsistent value emitted from name. Swift has a mechanism for expressing the grouping of changes together: isolation. When an actor or an isolated type is modified it is expected (enforced by the language itself) to be in a consistent state at the next suspension point. This means that if we can utilize the isolation that is safe for the type then the suspensions on that isolation should result in safe (and non torn values). This means that the implementation must be transactional upon that suspension; starting the transaction on the first trigger of a leading edge (the willSet) and then completing the transaction on the next suspension of that isolation.

The simple example of tearing would work as the following:

let person = Person(firstName: "", lastName: "")
// willSet \.firstName - start a transaction
person.firstName = "Jane"
// didSet \.firstName
// willSet \.lastName - the transaction is still dirty
person.lastName = "Appleseed"
// didSet \.lastName
// the next suspension the `name` property will be valid

Suspensions are any point where a task can be calling out to something where they await. Swift concurrency enforces safety around these by making sure that isolation is respected. Any time a function has a suspension point data associated with the type must be ready to be read by the definitions of actor isolation. In the previous example of the Person instance the firstName and lastName properties are mutated together in the same isolation, that means that no other access in that isolation can read those values when they are torn without the type being Sendable (able to be read from multiple isolations). That means that in the case of a non-Sendable type the access must be constrained to an isolation, and in the Sendable cases the mutation is guarded by some sort of mechanism like a lock, In either case it means that the next time one can read a safe value is on that same isolation of the safe access to start with and that happens on that isolations next suspension.

Observing at the next suspension point means that we can also address the second issue too; sharing. The expectation of observing a property from a type as an AsyncSequence is that multiple iterations of the same sequence from multiple tasks will emit the same values at the same iteration points. The following code is expected to emit the same values in both tasks.


let names = Observed { person.firstName + " " + person.lastName }

Task.detached {
  for await name in names {
    print("Task1: \(name)")
  }
}

Task.detached {
  for await name in names {
    print("Task2: \(name)")
  }
}

In this case both tasks will get the same values upon the same events. This can be achieved without needing an extra buffer since the suspension of each side of the iteration are continuations resuming all together upon the accessor's execution on the specified isolation. This facilitates subject-like behavior such that the values are sent from the isolation for access to the iteration's continuation.

Putting this together grants a signature as such:

public struct Observed<Element: Sendable, Failure: Error>: AsyncSequence, Sendable {
  public init(
    isolation: isolated (any Actor)? = #isolation,
    @_inheritActorContext _ emit: @Sendable @escaping () throws(Failure) -> Element?
  )
}

Picking the initializer apart first captures the current isolation of the creation of the Observed instance. Then it captures a Sendable closure that inherits that current isolation. This means that the closure may only execute on the captured isolation. That closure is run to determine which properties are accessed by using Observation's withObservationTracking. So any access to a tracked property of an @Observable type will compose for the determination of which properties to track.

The closure is not run immediately it is run asynchronously upon the first call to the iterator's next method. This establishes the first tracking state for Observation by invoking the closure inside a withObservationTracking on the implicitly specified isolation. Then upon the first willSet it will enqueue on to the isolation a new execution of the closure and finishing the transaction to prime for the next call to the iterator's next method.

The closure has two other features that are important for common usage; firstly the closure is typed-throws such that any access to that emission closure will potentially throw an error if the developer specifies. This allows for complex composition of potentially failable systems. Any thrown error will mean that the Observed sequence is complete and loops that are currently iterating will terminate with that given failure. Subsequent calls then to next on those iterators will return nil - indicating that the iteration is complete. Furthermore the emit closure also has a nullable result which indicates the
sequence is finished without failure.

The nullable result indication can then be easily used with weak references to @Observable instances. This likely will be a common pattern of users of the Observed async sequence.

let names = Observed { [weak person] in 
  person?.name
}

This lets the Observed async sequence compose a value that represents a lifetime bound emission. That the subject is not strongly referenced and can terminate the sequence when the object is deinitialized.

Effect on ABI stability & API resilience

This provides no alteration to existing APIs and is purely additive. However it does have a few points of interest about future source compatibility; namely the initializer does ferry the inherited actor context as a parameter and if in the future Swift develops a mechanism to infer this without a user overridable parameter then there may be a source breaking ambiguity that would need to be disambiguated.

Notes to API authors

This proposal does not change the fact that the spectrum of APIs may range from favoring AsyncSequence properties to purely @Observable models. They both have their place. However the calculus of determining the best exposition may be slightly more refined now with Observed.

If a type is representative of a model and is either transactional in that some properties may be linked in their meaning and would be a mistake to read in a disjoint manner (the tearing example from previous sections), or if the model interacts with UI systems it now more so than ever makes sense to use @Observable especially with Observed now as an option. Some cases may have previously favored exposing those AsyncSequence properties and would now instead favor allowing the users of those APIs compose things by using Observed. The other side of the spectrum will still exist but now is more strongly relegated to types that have independent value streams that are more accurately described as AsyncSequence types being exposed. The suggestion for API authors is that now with Observed favoring @Observable perhaps should take more of a consideration than it previously did.

Alternatives Considered

There have been many iterations of this feature so far but these are some of the
highlights of alternative mechanisms that were considered.

Just expose a closure with didSet: This misses the mark with regards to concurrency safety but also faces a large problem with regards to transactionality. This would also be out sync with the expected behavior of existing observation uses like SwiftUI. The one benefit of that approach is that each setter call would have a corresponding callback and would be more simple to implement with the existing infrastructure. It was ultimately rejected because that would fall prey to the issue of tearing and the general form of composition was not as ergonomic as other solutions.

Expose an AsyncSequence based on didSet: This also falls to the same issues with the closure approach except is perhaps slightly more ergonomic to compose. This was also rejected due to the tearing problem stated in the proposal.

Expose an AsyncSequence property extension based on KeyPath: This could be adapted to the willSet and perhaps transactional models, but faces problems when attempting to use KeyPath across concurrency domains (since by default they are not Sendable). The implementation of that approach would require considerable improvement to handling of KeyPath and concurrency (which may be an optimization path that could be considered in the future if the API merits it). As it stands however the KeyPath approach in comparison to the closure initializer is considerably less easy to compose.

One consideration that is an addition is to add an unsafe initializer that lets an isolation be specified manually rather than picking it up from the inferred context. This is something that if usage cases demand it is possible and worth entertaining. But that is completely additive and can be bolted on later-on.

43 Likes

Hmm… the examples seem to start from the assumption that person is an instance of a type that used the Observable macro to opt-in to Swift Observation. If a product engineer "rolled their own" Observation Support directly in their type because they were for some reason blocked on the Observable macro… would the new Observed type be expected to work correctly… or is using Observed without the Observable macro considered to be unsupported?

If a product engineer "rolled their own" Observation Support directly in their type

As long as they are calling the ObservationRegistrar methods it will work as expected.

3 Likes

it seems a shame that it uses a SwiftUI macro in the examples.

The macro used is @Observable which is from the Observation module not SwiftUI. So that can be used on Linux etc just fine ;)

9 Likes

One of the questions about concurrency I see a lot is strategies for deterministic and predictable tests. Will product engineers building against Observed be able to make any kind of safe assumptions about any "backpressure" — or the absence of backpressure — on the delivery of values?

Could there be support for product engineers "injecting" their own custom AsyncSequence type to be used to deliver values? Or would Observed have to be locked down to its own private AsyncSequence type? Would product engineers have to look for another direction if they want to inject another AsyncSequence type?

2 Likes

I implemented an Observable -> AsyncSequence helper for SwiftClaude.

My experience is that there were too many choices that were domain specific to have a single general solution (ie what happens when an observed object deinitializes). This is further complicated since you can do foo.bar.x + baz.z where foo, bar and baz are independent objects, and can be independently deinitialized.

The “yield on suspend” behavior is complicated too since it is opinionated about how soon the closure’s value should be yielded (on the next suspend in this pitch vs whenever the loop body is invoked for SwiftClaude).

I think the one missing piece in the API currently is a second callback from withObservationTracking which is called if onChange is will never be called (due to all observed objects being deinitialized). With that small addition, the API in this pitch, SwiftClaude, and many other arrangements becomes trivial to implement.

Does this mean that this pitch will also enable single-producer multi-consumer for all AsyncSequences?

I find that to be the biggest limitation around properly building APIs that vend AsyncSequences.

5 Likes

Seems like this would be a feature of the new Observed AsyncSequence, specifically, but would not change capabilities of other sequence types. However, if I’m missing something, or there is a way to compose Observed with another sequence, I agree that would be a nice bonus.

Just want to say that this is amazing, the missing piece of the puzzle. Thanks for building all these tools. But I wonder, when will we be able to use this? Will we even be able to grab the Observed code from the swift repo beforehand?

2 Likes

Trying to think about how to use this to test an observable, and I'm not really sure that it helps:

@Observable
class MyViewModel {
   var interestingState = 0

   func doSomethingInTheUI() async {
       interestingState += 1
       _ = try? await Task.sleep(...)
       interestingState += 1
   }
}

@Test
func testDoingStuffInTheUI() async {
    let subject = MyViewModel()
    let seq = Observed { subject.interestingState }
    let task = Task {
        var results = [Int]()
        for await interestingState in seq {
            results.append(interestingState)
        }
        // do we actually have any kind of guarantee
        // of which elements show up here?
        #expect(results == [0, 1, 2])
    }
    // bug here, not sure how to avoid it:
    // this line may progress before the loop
    // in the task first calls next(), which would
    // cause us to miss values.
    await subject.doSomethingInTheUI()
    // how do I finish the sequence, here? Otherwise
    // the loop above is infinite, or I need to insert an
    // explicit .prefix(3), which has other problems
    await task.value
}
1 Like

This is a very welcome addition to the Observation functionality, thank you!

Aside from the availability, there is one more limitation that is preventing me from using @Observable for a wider range of use cases: it doesn't work with actor types.

Sorry if this was asked before, but is it possible (are there any plans) to make Observation work with actor types?

Philippe,

Thank you for this. It addresses needs I have in my own code. My only concern is that I'll over-use it. I look forward to trying it out "in anger" but I really like what you've proposed.

Daniel

2 Likes

For those who want to try things out: I put up a PR of the initial implementation here. It is made to be able to be standalone (i.e. you could grab the code independently and use it in a test project without needing to build all of Swift).

Im not sure active back-pressure applies here since the suspension cannot infer back to the set of objects that would change. Passively it of course uses async/await which does have a demand basis of 1 - so that passive back-pressure does exist.

Per your question w.r.t. to injection; that is well out of scope for this change and would really need something more involved than I'd like to tack on to this proposal (since it would most likely require some deep integration with the swift concurrency runtimes). However I would suggest you to take a look at design patterns using some AsyncSequence<Element, Failure> that can let you have modular backing sequences for the same properties.

This could probably be fixed by emitting a change event to the registrar inside of Observation itself when the death hits; that would go nicely with the nullable result of the closure.

Indirectly yes? I it is the first of its' kind and one could potentially build some things around Observed I would imagine.

It was a stumbling block for @Observed when that was introduced; it is still blocked on KeyPath properly supporting actor.

5 Likes

This is great! I think I know the answers but I have two questions:

  1. Does it emit whatever is already there first or only the next change?
  2. If you are observing from the same isolation as the changes (say main thread), is the new value sent immediately or still asynchronously (like on the equivalent of the next run loop or asyncing back to main)?
2 Likes

So following up on this: I talked with some of the SwiftUI folks and this is probably just a bug in the implementation of Observation generally and not even something related to this pitch (it just brings it to light via your commentary). I have a speculative fix (and test) here that will hopefully resolve it for all observation uses.

3 Likes

The emit parameter is declared as follows:

@_inheritActorContext _ emit: @Sendable @escaping () throws(Failure) -> Element?

Why must it be @Sendable? Couldn't this be sending instead? For all the same reasons why Task operations are sending and not @Sendable. I think a Sendable constraint will be unnecessarily burdensome — unless I'm missing the reason why Sendable is an unavoidable constraint. If the emit closure was sending one could write the following:

class Decorator /* not Sendable */ {
    let fakeMustache: String

    func decorate(_ input: String) -> String {
        fakeMustache + input
    }
}

// in my observation code ...

let decorator = Decorator(fakeMustache: "broom")
let obs = Observed {
    return decorator.decorate(person.name)
}
// compiler enforces my pinky-swear not to touch `decorator` again

Whereas with the @Sendable requirement, there would be additional hoops to jump through, limitations, which are hard to demonstrate briefly here but were motivations behind the introduction of sending as an alternative to @Sendable in certain situations.

1 Like

I've had a quick play with the initial implementation.

It's a huge positive, from me – this is exactly the sort of problem I've been forced to use CurrentValueSubject to solve in my current app.

The timing (the fact that you don't get notified for every single update but instead get periodic updates) means that this wouldn't be a complete drop-in replacement for CurrentValueSubject – there's still some kinds of communication where you want every single value communicated – but that's inherent to the @Observable concept.

Obviously, the inability to put @Observable on an actor is a big pain. There's also seems to be some ergonomic issues weakly capturing @Observable objects on an actor (It's unrelated to this proposal but when you use [weak self], where self is an actor, the closure stops inheriting the isolation, correctly.)

For anyone else looking to play with the sample implementation:

  1. You can run it in a Mac command line app built in Xcode but you need to use a recent Swift "Development Snapshot" toolchain (I used March 4)
  2. You need to turn off "Enabled Hardened Runtime" in the Xcode settings (otherwise you'll get "Symbol not found" issues as dyld won't be able to open the toolchain's libswift_Concurrency.dylib)
  3. I took the Observed type from the "initial implementation" which can be directly accessed, here: swift/stdlib/public/Observation/Sources/Observation/Observed.swift at 98d782ff47b72538c9207ffe06fae6ce28b3db0b · swiftlang/swift · GitHub

Hmm. I am seeing a problem where values in the sequence are not always emitted (it can be any non-initial value but it's most annoying when it's the last value).

I'm running the following code using the approach described in my previous comment. In the following code, increment() and end() are called back-to-back and the result seems to be non-deterministic about whether Observed will emit 0 then nil, (in which case the sequence will end as expected) or whether it will emit 0 then 1 and then the sequence will (unexpectedly) never complete.

It looks like end() may be getting called while the iterator is between calls to next()... and in that case there's simply no observation at all? I think there needs to be a way to detect if a change has occurred between calls to next(), otherwise this isn't going to work reliably.

import Observation

actor SomeActor {
    @Observable
    class SomeObservable {
        var value: Int? = 0
    }
    var something = SomeObservable()
    func somethingSequence() -> Observed<Int, Never> {
        return Observed {
            print("Evaluating contents \(self.something.value == nil)")
            return self.something.value
        }
    }
    func increment() {
        print("Incremented")
        something.value = something.value.map { $0 + 1 }
    }
    
    func end() {
        print("Ending")
        something.value = nil
    }
}

let actor = SomeActor()

let task = Task {
    print("Start of task")
    let sequence = await actor.somethingSequence()
    for await value in sequence {
        print("The current value is \(value)")
    }
    print("End of task")
}

try? await Task.sleep(for: .seconds(1))
await actor.increment()
await actor.end()
try? await Task.sleep(for: .seconds(1))
await task.value

print("End of process")
1 Like

Yeah I was wondering about this as well. If you have a random @Observable that doesn't happen to be on a global actor, then it is highly unlikely to be sendable because it has mutable variables by design — that's why you want to observer it! So this seems very limiting unless I am missing something.

Do the basic examples given even compile because Person and Pet etc aren't sendable?

+1, I also would like to know about these two questions.