Skip to content

Commit

Permalink
FHIRStore actor isolation (#26)
Browse files Browse the repository at this point in the history
# `FHIRStore` actor isolation

## ♻️ Current situation & Problem
As references in #25, the current `FHIRStore` implementation is not
ideal as it still uses preconcurrency locking mechanisms. Therefore, the
`FHIRStore` should be reimplemented to use Swift's structured
concurrency and isolation mechanisms.


## ⚙️ Release Notes 
- `FHIRStore` actor isolation with structured concurrency, not relying
on locks anymore.
- `FHIRResource`s are not `Sendable` anymore (underlying ModelsR4 class
is not `Sendable`)

## 📚 Documentation
Documented all isolation thoughts


## ✅ Testing
--


## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md):
- [X] I agree to follow the [Code of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
philippzagar authored Jan 19, 2025
1 parent a2e68a2 commit aa1f1b5
Show file tree
Hide file tree
Showing 12 changed files with 305 additions and 212 deletions.
43 changes: 19 additions & 24 deletions Sources/SpeziFHIR/FHIRResource/FHIRResource+Category.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import enum ModelsDSTU2.ResourceProxy
extension FHIRResource {
/// Enum representing different categories of FHIR resources.
/// This categorization helps in classifying FHIR resources into common healthcare scenarios and types.
enum FHIRResourceCategory {
enum FHIRResourceCategory: CaseIterable {
/// Represents an observation-type resource (e.g., patient measurements, lab results).
case observation
/// Represents an encounter-type resource (e.g., patient visits, admissions).
Expand All @@ -34,32 +34,27 @@ extension FHIRResource {
case medication
/// Represents other types of resources not covered by the above categories.
case other
}

var storeKeyPath: KeyPath<FHIRStore, [FHIRResource]> {
switch self.category {
case .observation:
\.observations
case .encounter:
\.encounters
case .condition:
\.conditions
case .diagnostic:
\.diagnostics
case .procedure:
\.procedures
case .immunization:
\.immunizations
case .allergyIntolerance:
\.allergyIntolerances
case .medication:
\.medications
case .other:
\.otherResources


/// The ``FHIRStore`` property key path of the resource.
///
/// - Note: Needs to be isolated on `MainActor` as the respective ``FHIRStore`` properties referred to by the `KeyPath` are isolated on the `MainActor`.
@MainActor var storeKeyPath: KeyPath<FHIRStore, [FHIRResource]> {
switch self {
case .observation: \.observations
case .encounter: \.encounters
case .condition: \.conditions
case .diagnostic: \.diagnostics
case .procedure: \.procedures
case .immunization: \.immunizations
case .allergyIntolerance: \.allergyIntolerances
case .medication: \.medications
case .other: \.otherResources
}
}
}


/// Category of the FHIR resource.
///
/// Analyzes the type of the underlying resource and assigns it to an appropriate category.
Expand Down
10 changes: 5 additions & 5 deletions Sources/SpeziFHIR/FHIRResource/FHIRResource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@
//

import Foundation
@preconcurrency import ModelsDSTU2
@preconcurrency import ModelsR4
import ModelsDSTU2
import ModelsR4


/// Represents a FHIR (Fast Healthcare Interoperability Resources) entity.
///
/// Handles both DSTU2 and R4 versions, providing a unified interface to interact with different FHIR versions.
public struct FHIRResource: Sendable, Identifiable, Hashable {
public struct FHIRResource: Identifiable, Hashable {
/// Version-specific FHIR resources.
public enum VersionedFHIRResource: Sendable, Hashable {
public enum VersionedFHIRResource: Hashable {
/// R4 version of FHIR resources.
case r4(ModelsR4.Resource) // swiftlint:disable:this identifier_name
// DSTU2 version of FHIR resources.
/// DSTU2 version of FHIR resources.
case dstu2(ModelsDSTU2.Resource)
}

Expand Down
230 changes: 117 additions & 113 deletions Sources/SpeziFHIR/FHIRStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
// SPDX-License-Identifier: MIT
//

import Combine
import Foundation
import Observation
import class ModelsR4.Bundle
import enum ModelsDSTU2.ResourceProxy
Expand All @@ -19,149 +17,155 @@ import Spezi
/// The ``FHIRStore`` is automatically injected in the environment if you use the ``FHIR`` standard or can be used as a standalone module.
@Observable
public final class FHIRStore: Module,
EnvironmentAccessible,
DefaultInitializable,
@unchecked Sendable /* `unchecked` `Sendable` conformance fine as access to `_resources` protected by `NSLock` */ {
private let lock = NSLock()
@ObservationIgnored private var _resources: [FHIRResource]


/// Allergy intolerances.
public var allergyIntolerances: [FHIRResource] {
EnvironmentAccessible,
DefaultInitializable,
Sendable {
@MainActor private var _resources: [FHIRResource] = []


/// `FHIRResource`s with category `allergyIntolerance`.
@MainActor public var allergyIntolerances: [FHIRResource] {
access(keyPath: \.allergyIntolerances)
return lock.withLock {
_resources.filter { $0.category == .allergyIntolerance }
}
return _resources.filter { $0.category == .allergyIntolerance }
}
/// Conditions.
public var conditions: [FHIRResource] {

/// `FHIRResource`s with category `condition`.
@MainActor public var conditions: [FHIRResource] {
access(keyPath: \.conditions)
return lock.withLock {
_resources.filter { $0.category == .condition }
}
return _resources.filter { $0.category == .condition }
}
/// Diagnostics.
public var diagnostics: [FHIRResource] {

/// `FHIRResource`s with category `diagnostic`.
@MainActor public var diagnostics: [FHIRResource] {
access(keyPath: \.diagnostics)
return lock.withLock {
_resources.filter { $0.category == .diagnostic }
}
return _resources.filter { $0.category == .diagnostic }
}
/// Encounters.
public var encounters: [FHIRResource] {

/// `FHIRResource`s with category `encounter`.
@MainActor public var encounters: [FHIRResource] {
access(keyPath: \.encounters)
return _resources.filter { $0.category == .encounter }
}
/// Immunizations.
public var immunizations: [FHIRResource] {

/// `FHIRResource`s with category `immunization`
@MainActor public var immunizations: [FHIRResource] {
access(keyPath: \.immunizations)
return lock.withLock {
_resources.filter { $0.category == .immunization }
}
return _resources.filter { $0.category == .immunization }
}
/// Medications.
public var medications: [FHIRResource] {

/// `FHIRResource`s with category `medication`.
@MainActor public var medications: [FHIRResource] {
access(keyPath: \.medications)
return lock.withLock {
_resources.filter { $0.category == .medication }
}
return _resources.filter { $0.category == .medication }
}
/// Observations.
public var observations: [FHIRResource] {

/// `FHIRResource`s with category `observation`.
@MainActor public var observations: [FHIRResource] {
access(keyPath: \.observations)
return lock.withLock {
_resources.filter { $0.category == .observation }
}
}

/// Other resources that could not be classified on the other categories.
public var otherResources: [FHIRResource] {
access(keyPath: \.otherResources)
return lock.withLock {
_resources.filter { $0.category == .other }
}
return _resources.filter { $0.category == .observation }
}
/// Procedures.
public var procedures: [FHIRResource] {

/// `FHIRResource`s with category `procedure`.
@MainActor public var procedures: [FHIRResource] {
access(keyPath: \.procedures)
return lock.withLock {
_resources.filter { $0.category == .procedure }
}
return _resources.filter { $0.category == .procedure }
}


public required init() {
self._resources = []

/// `FHIRResource`s with category `other`.
@MainActor public var otherResources: [FHIRResource] {
access(keyPath: \.otherResources)
return _resources.filter { $0.category == .other }
}


/// Inserts a FHIR resource into the store.


/// Create an empty ``FHIRStore``.
public required init() {}


/// Inserts a FHIR resource into the ``FHIRStore``.
///
/// - Parameter resource: The `FHIRResource` to be inserted.
@MainActor
public func insert(resource: FHIRResource) {
withMutation(keyPath: resource.storeKeyPath) {
lock.withLock {
_resources.append(resource)
}
}
_$observationRegistrar.willSet(self, keyPath: resource.category.storeKeyPath)

_resources.append(resource)

_$observationRegistrar.didSet(self, keyPath: resource.category.storeKeyPath)
}
/// Removes a FHIR resource from the store.

/// Inserts a ``Collection`` of FHIR resources into the ``FHIRStore``.
///
/// - Parameter resource: The `FHIRResource` identifier to be inserted.
public func remove(resource resourceId: FHIRResource.ID) {
lock.withLock {
guard let resource = _resources.first(where: { $0.id == resourceId }) else {
return
}

withMutation(keyPath: resource.storeKeyPath) {
_resources.removeAll(where: { $0.id == resourceId })
}
/// - Parameter resources: The `FHIRResource`s to be inserted.
@MainActor
public func insert<T: Collection>(resources: T) where T.Element == FHIRResource {
let resourceCategories = Set(resources.map(\.category))

for category in resourceCategories {
_$observationRegistrar.willSet(self, keyPath: category.storeKeyPath)
}

self._resources.append(contentsOf: resources)

for category in resourceCategories {
_$observationRegistrar.didSet(self, keyPath: category.storeKeyPath)
}
}
/// Loads resources from a given FHIR `Bundle`.

/// Loads resources from a given FHIR `Bundle` into the ``FHIRStore``.
///
/// - Parameter bundle: The FHIR `Bundle` containing resources to be loaded.
public func load(bundle: Bundle) {
public func load(bundle: sending Bundle) async {
let resourceProxies = bundle.entry?.compactMap { $0.resource } ?? []

var resources: [FHIRResource] = []

for resourceProxy in resourceProxies {
insert(resource: FHIRResource(resource: resourceProxy.get(), displayName: resourceProxy.displayName))
if Task.isCancelled {
return
}

resources.append(
FHIRResource(
resource: resourceProxy.get(),
displayName: resourceProxy.displayName
)
)
}

if Task.isCancelled {
return
}

await insert(resources: resources)
}

/// Removes all resources from the store.

/// Removes a FHIR resource from the ``FHIRStore``.
///
/// - Parameter resource: The `FHIRResource` identifier to be inserted.
@MainActor
public func remove(resource resourceId: FHIRResource.ID) {
guard let resource = _resources.first(where: { $0.id == resourceId }) else {
return
}

_$observationRegistrar.willSet(self, keyPath: resource.category.storeKeyPath)

_resources.removeAll { $0.id == resourceId }

_$observationRegistrar.didSet(self, keyPath: resource.category.storeKeyPath)
}

/// Removes all resources from the ``FHIRStore``.
@MainActor
public func removeAllResources() {
lock.withLock {
// Not really ideal but seems to be a path to ensure that all observables are called.
_$observationRegistrar.willSet(self, keyPath: \.allergyIntolerances)
_$observationRegistrar.willSet(self, keyPath: \.conditions)
_$observationRegistrar.willSet(self, keyPath: \.diagnostics)
_$observationRegistrar.willSet(self, keyPath: \.encounters)
_$observationRegistrar.willSet(self, keyPath: \.immunizations)
_$observationRegistrar.willSet(self, keyPath: \.medications)
_$observationRegistrar.willSet(self, keyPath: \.observations)
_$observationRegistrar.willSet(self, keyPath: \.otherResources)
_$observationRegistrar.willSet(self, keyPath: \.procedures)
_resources = []
_$observationRegistrar.didSet(self, keyPath: \.allergyIntolerances)
_$observationRegistrar.didSet(self, keyPath: \.conditions)
_$observationRegistrar.didSet(self, keyPath: \.diagnostics)
_$observationRegistrar.didSet(self, keyPath: \.encounters)
_$observationRegistrar.didSet(self, keyPath: \.immunizations)
_$observationRegistrar.didSet(self, keyPath: \.medications)
_$observationRegistrar.didSet(self, keyPath: \.observations)
_$observationRegistrar.didSet(self, keyPath: \.otherResources)
_$observationRegistrar.didSet(self, keyPath: \.procedures)
for category in FHIRResource.FHIRResourceCategory.allCases {
_$observationRegistrar.willSet(self, keyPath: category.storeKeyPath)
}

_resources = []

for category in FHIRResource.FHIRResourceCategory.allCases {
_$observationRegistrar.didSet(self, keyPath: category.storeKeyPath)
}
}
}
4 changes: 2 additions & 2 deletions Sources/SpeziFHIRHealthKit/FHIRStore+HealthKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ extension FHIRStore {
public func add(sample: HKSample) async {
do {
let resource = try await transform(sample: sample)
insert(resource: resource)
await insert(resource: resource)
} catch {
print("Could not transform HKSample: \(error)")
}
Expand All @@ -38,7 +38,7 @@ extension FHIRStore {
/// Remove a HealthKit sample delete object from the FHIR store.
/// - Parameter sample: The sample delete object that should be removed.
public func remove(sample: HKDeletedObject) async {
remove(resource: sample.uuid.uuidString)
await remove(resource: sample.uuid.uuidString)
}


Expand Down
Loading

0 comments on commit aa1f1b5

Please sign in to comment.