Skip to content

Conversation

@requilence
Copy link
Contributor

Refactored membership data management to work with new middleware caching behavior for faster app startup.

Problem

Middleware now returns cached membership/tiers data immediately (with noCache: false) instead of blocking on network calls. App startup was slow because we were forcing network calls with noCache: true.

Changes

Single Source of Truth Architecture

  • MembershipStatusStorage now owns all membership data (status + tiers)
  • Added tiersPublisher and currentTiers to protocol
  • UI subscribes to storage publishers instead of calling service directly

Fast Startup

  • Changed startSubscription() to use noCache: false (uses middleware in-memory cache)
  • Added refreshMembership() with noCache: true for explicit refresh after IAP purchases

Event Handling

  • Added membershipTiersUpdate event handling
  • membershipUpdate: Updates status using event data + existing tiers
  • membershipTiersUpdate: Builds all tiers from event data
  • Simple guard skips status update if tiers not available yet (edge case)

Tier Filtering

  • Added isTest and iosProductID properties to MembershipTier model
  • Storage builds ALL tiers without filtering
  • UI filters tiers when rendering based on:
    • Test tiers (via FeatureFlags.membershipTestTiers)
    • iOS compatibility (has iosProductID OR is current user tier)

Files Modified

  • MembershipStatusStorage.swift - Core storage implementation
  • MembershipStatusStorageProtocol - Added tiers publishers
  • MembershipCoordinatorModel.swift - Subscribe to storage, filter tiers in UI
  • MembershipTier.swift - Added isTest, iosProductID properties
  • MembershipModelBuilder.swift - Pass new properties when building tiers
  • MembershipTier+Mocks.swift - Updated mocks
  • MembershipStatusStorageMock.swift - Added tiers support

refactor the membership storage and event handling
@requilence requilence requested a review from a team as a code owner October 17, 2025 14:01
@claude
Copy link
Contributor

claude bot commented Oct 17, 2025

Bugs/Issues

Race condition in event handling (MembershipStatusStorage.swift:73-91, 93-102)

  • Both membershipUpdate and membershipTiersUpdate event handlers create new Task blocks inside the for-loop
  • These tasks run concurrently without synchronization, creating potential race conditions when updating _status and _tiers
  • If multiple events arrive in quick succession, the order of updates to @published properties becomes non-deterministic
  • Fix: Remove the Task wrappers since handle(events:) is already called from a @mainactor task (line 64)

Missing tiers refresh (MembershipStatusStorage.swift:55-58)

  • refreshMembership() only refreshes status with noCache: true, but doesn't refresh tiers
  • If tiers data is stale after IAP purchase, UI won't show updated tier information
  • Fix: Add tiers refresh call with noCache: true

Silent error swallowing (MembershipCoordinatorModel.swift:65-68)

  • retryLoadTiers() calls refreshMembership() but doesn't check if it succeeds
  • If refresh fails, showTiersLoadingError remains false, leaving user with no feedback
  • Fix: Catch errors from refreshMembership() and set showTiersLoadingError = true on failure

Best Practices

Debug print statements in production (MembershipStatusStorage.swift:76, 87, 89)

  • Three print() statements will appear in production logs
  • Use proper logging framework or anytypeAssertionFailure() for errors per project conventions

Missing error handling (MembershipStatusStorage.swift:55-58)

  • refreshMembership() silently falls back to old _status on error using try? fallback pattern
  • No logging or analytics tracking when refresh fails after IAP purchase
  • Consider logging failures for debugging

Summary: 🚨 Major Issues - Race conditions in event handling, incomplete refresh logic, and silent error swallowing need fixing

Comment on lines 24 to 29
var tiers: [MembershipTier] {
let currentTierId = userMembership.tier?.type.id ?? 0
return allTiers
.filter { FeatureFlags.membershipTestTiers || !$0.isTest }
.filter { !$0.iosProductID.isEmpty || $0.type.id == currentTierId }
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we use this on the UI level, it will really affect performance. It's better to make this a published property and discard @Published private var allTiers: [MembershipTier] = []
because seems like it's not used anymore.

Also naming is really ambiguous. We have allTiers and tiers with no significant distinction and ability to understand what is what.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree. UX fields should not be computable.

}

case .membershipTiersUpdate(let update):
Task {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Task created inside main actor will still be called on the main actor. If you want to do it in the backgroind you need to call Task.detached

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or remove main action from storage (legacy). Its better.

Comment on lines +105 to +106
public let isTest: Bool
public let iosProductID: String
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have this properties available inside Anytype_Model_MembershipTierData I am not sure we need to introduce them here once again

)
_status.tier.flatMap { AnytypeAnalytics.instance().logChangePlan(tier: $0) }
AnytypeAnalytics.instance().setMembershipTier(tier: _status.tier)
print("[Membership] Updated membership status - tier: \(_status.tier?.name ?? "none")")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As claude mentioned - no prints in production code. It should be either assert or remove it

@Published private var _status: MembershipStatus = .empty


var tiersPublisher: AnyPublisher<[MembershipTier], Never> { $_tiers.eraseToAnyPublisher() }
Copy link
Collaborator

@mgolovko mgolovko Oct 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legacy way. Try to use Async stream

Copy link
Contributor Author

@requilence requilence Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reworked to use asynchronous streams


private var subscription: AnyCancellable?

Copy link
Collaborator

@mgolovko mgolovko Oct 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please disable "trim whitespace-only lines" in Xcode and rollback all this changes

…o ios-5365-membership-data-fetching-refactoring-integration-ios-refactor

# Conflicts:
#	Libraryfile
#	Modules/ProtobufMessages/Sources/Protocol/Events/Anytype_Event.Membership.TiersUpdate.swift
#	Modules/ProtobufMessages/Sources/Protocol/Events/Anytype_Event.Message.swift
membershipStatusStorage.statusPublisher.receiveOnMain().assign(to: &$userMembership)

statusTask = Task { [weak self] in
guard let self else { return }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retain cycle

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use view.task.
Look example

}

tiersTask = Task { [weak self] in
guard let self else { return }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

private func combinedStream() -> AsyncStream<(MembershipStatus, [MembershipTier])> {
let storage = membershipStatusStorage
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use combineLastes in AsyncAlgoritms

showTiersLoadingError = true

func retryLoadTiers() {
Task {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EmptyStateView support async throws methods ->retryLoadTiers() async throws

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We try not to write Task { wherever possible.

}
.onChange(of: reason) { _, reason in
model.updateState(reason: reason)
Task {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make task(id: reason)

.foregroundColor(.Text.primary)
case nil:
Rectangle().hidden().onAppear {
Rectangle().hidden().task {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why task here?

let storage = Container.shared.membershipStatusStorage.resolve()
storage.statusPublisher.receiveOnMain().assign(to: &$userMembership)
statusTask = Task { [weak self] in
guard let self else { return }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let storage = Container.shared.membershipStatusStorage.resolve()
storage.statusPublisher.receiveOnMain().assign(to: &$membership)
statusTask = Task { [weak self] in
guard let self else { return }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private var _status: MembershipStatus = .empty
private var _tiers: [MembershipTier] = []

private var statusContinuations: [UUID: AsyncStream<MembershipStatus>.Continuation] = [:]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use AsyncToManyStream


nonisolated init() { }

nonisolated func statusStream() -> AsyncStream<MembershipStatus> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are write var for publisher and stream without filters

_status
}

nonisolated func tiersStream() -> AsyncStream<[MembershipTier]> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
}

func currentTiers() async -> [MembershipTier] {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

var

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AsyncToManyStream allows you to read the last value in a nonisolated var. We try to read the var synchronously. This greatly simplifies the code.


AnytypeAnalytics.instance().setMembershipTier(tier: _status.tier)
membershipUpdateTask?.cancel()
membershipUpdateTask = Task { [weak self, builder] in
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why task? You can mark handle as async and call any async methods inside

Copy link
Collaborator

@mgolovko mgolovko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are many tasks in an asynchronous context whose purpose is unclear. There’s code that could be simplified by using solutions from project.

@requilence requilence closed this Nov 3, 2025
@github-actions github-actions bot locked and limited conversation to collaborators Nov 3, 2025
@requilence
Copy link
Contributor Author

decided to reimplement this by ios team, as original PR became bigger than I expected

@ignatovv ignatovv deleted the ios-5365-membership-data-fetching-refactoring-integration-ios-refactor branch November 26, 2025 14:17
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants