Skip to content

Draft: Add primitives, identity manager w adapter#381

Closed
ianrumac wants to merge 1 commit intodevelopfrom
ir/refactor/actors
Closed

Draft: Add primitives, identity manager w adapter#381
ianrumac wants to merge 1 commit intodevelopfrom
ir/refactor/actors

Conversation

@ianrumac
Copy link
Collaborator

@ianrumac ianrumac commented Mar 10, 2026

Changes in this pull request

  • just a draft right now

Checklist

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run ktlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

Greptile Summary

This draft PR replaces the lock/runBlocking-based IdentityManager with a Redux-style unidirectional data-flow architecture: a central Engine processes events from an unlimited Channel, pure Reducer lambdas produce the next SdkState, and side effects are accumulated in an Fx object and dispatched asynchronously. A new IdentityManagerActor.kt houses all identity state, reducers, and effects, while IdentityManager becomes a thin facade with an unchanged public API. ConfigManager gains two ConfigReady dispatch calls to signal the engine when configuration is available.

Key concerns to resolve before merging:

  • Deferred-effect deadlock after user switchFullResetOnIdentify sets configReady = false, but ConfigManager only dispatches ConfigReady on an actual config fetch or refresh. Because no config refresh is triggered by the user-switch flow, ResolveSeed, FetchAssignments, and ReevaluateTestMode may stall indefinitely until the next periodic refresh — a silent regression from the old awaitFirstValidConfig() path.
  • Unsafe unchecked cast in Engine — every SdkEvent is cast to Reducer<SdkState> at runtime; IdentityState.Updates (which is Reducer<IdentityState>) dispatched directly would throw a ClassCastException with no compile-time protection.
  • Thread executor resource leakIdentityManager creates a newSingleThreadExecutor dispatcher that is never closed, leaking an OS thread per instance.
  • Impure reducer via Fx.log()Fx.log() performs immediate I/O (calls Logger.debug()) instead of queuing an Effect.Log, undermining the pure-reducer contract; Effect.Log is declared but dead.
  • Flaky testsThread.sleep(100/200) is used throughout both test classes to synchronise with the engine's real executor thread, which the TestCoroutineScheduler cannot control.

Confidence Score: 2/5

  • Not safe to merge — the deferred-effect stall after a user switch is a behavioural regression, the unchecked engine cast is a latent runtime crash, and the thread leak is a resource issue.
  • The architectural direction (unidirectional data flow, pure reducers, effect system) is sound and the public API is preserved, but several correctness issues need resolving: a potential indefinite stall of identity effects after a user switch, a runtime-only type-safety hole in the engine's event dispatch, an OS-thread leak, an impure reducer, and flaky time-based tests. Given the draft status these are expected to be addressed, but the issues are significant enough to block merging as-is.
  • IdentityManagerActor.kt (deferred-effect deadlock), Engine.kt (unsafe cast), IdentityManager.kt (thread leak), Fx.kt (impure logging), and both test files (Thread.sleep synchronisation).

Important Files Changed

Filename Overview
superwall/src/main/java/com/superwall/sdk/misc/primitives/Engine.kt New core engine loop using an unlimited Channel; contains an unchecked cast from SdkEvent to Reducer<SdkState> that would silently fail if an IdentityState.Updates event is dispatched directly.
superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt New file housing IdentityState, all its reducers, and IdentityEffect sealed class; contains a deferred-effect deadlock risk after user-switch and unreachable code in the Identify reducer.
superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt Refactored to be a thin facade over the Engine; creates a newSingleThreadExecutor dispatcher that is never closed, leaking a thread per IdentityManager instance.
superwall/src/main/java/com/superwall/sdk/misc/primitives/Fx.kt Effect accumulator used inside reducers; Fx.log() performs an immediate logging side effect rather than queuing an Effect.Log, breaking the pure-reducer model.
superwall/src/main/java/com/superwall/sdk/misc/primitives/Effects.kt Defines the Effect sealed interface hierarchy; Effect.Log is declared but never produced by Fx.log(), making it effectively dead code.
superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt Adds engine.dispatch(SdkState.Updates.ConfigReady) calls at config retrieval and refresh points; minimal change but critical for the deferred-effect flow — does not dispatch after a user-switch-triggered reset, which is the root of the deadlock risk.
superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt Comprehensive new test suite covering identity lifecycle; uses Thread.sleep throughout for async synchronisation, making tests susceptible to flakiness on slow CI machines.
superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt Focused tests for userAttributes consistency across init/identify/reset/merge scenarios; also relies on Thread.sleep for synchronisation.

Sequence Diagram

sequenceDiagram
    participant Caller as Caller (Superwall.kt)
    participant IM as IdentityManager
    participant Engine as Engine (Channel)
    participant Reducer as SdkState.Updates / IdentityState.Updates
    participant Fx as Fx (effect accumulator)
    participant Runner as EffectRunner
    participant Config as ConfigManager
    participant Storage as Storage

    Note over Caller,Storage: Identify new user (user switch scenario)
    Caller->>IM: identify("user-B")
    IM->>Engine: dispatch(UpdateIdentity(Identify("user-B")))
    Engine->>Reducer: applyOn(Fx, SdkState)
    Reducer->>Fx: dispatch(FullResetOnIdentify) → configReady=false
    Reducer->>Fx: effect(CompleteReset)
    Reducer->>Fx: persist(AppUserId, AliasId, Seed, UserAttributes)
    Reducer->>Fx: track(IdentityAlias)
    Reducer->>Fx: defer(until={configReady}) [ResolveSeed, FetchAssignments, ReevaluateTestMode]
    Reducer->>Fx: effect(CheckWebEntitlements)
    Engine->>Runner: launch CompleteReset
    Runner->>Caller: completeReset()
    Engine->>Runner: launch CheckWebEntitlements
    Engine->>Runner: launch persist/track effects
    Runner->>Storage: write(AppUserId, AliasId, Seed, UserAttributes)

    Note over Engine,Config: Deferred effects wait for configReady=true
    Config-->>Engine: dispatch(ConfigReady) [only on next config fetch/refresh]
    Engine->>Fx: configReady=true → release deferred batch
    Engine->>Runner: launch ResolveSeed
    Engine->>Runner: launch FetchAssignments
    Engine->>Runner: launch ReevaluateTestMode
Loading

Last reviewed commit: 9d7b577

Greptile also left 6 inline comments on this PR.

Comment on lines +96 to +100
defer(until = { it.configReady }) {
effect { IdentityEffect.ResolveSeed(sanitized) }
effect { IdentityEffect.FetchAssignments }
effect { IdentityEffect.ReevaluateTestMode(sanitized, base.aliasId) }
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Deferred effects may never fire after a user switch

When a user is switched (Identify reducer detects a different appUserId), SdkState.Updates.FullResetOnIdentify is dispatched, which sets configReady = false. The deferred effects block then waits until = { it.configReady }.

However, configReady is only set back to true when ConfigManager explicitly dispatches SdkState.Updates.ConfigReady — which only happens after an initial config retrieval or a refreshConfiguration() call. Since FullResetOnIdentify does not trigger a new config fetch in ConfigManager, and completeReset() only resets other managers (not ConfigManager), configReady may never flip back to true in the current session.

This means IdentityEffect.ResolveSeed, IdentityEffect.FetchAssignments, and IdentityEffect.ReevaluateTestMode would only run after the next periodic config refresh — a silent behavioural regression compared to the old configManager.configState.awaitFirstValidConfig() path, which found the already-cached config immediately.

Consider dispatching ConfigReady in ConfigManager explicitly after a reset, or reusing the existing cached config state to release deferred effects when the cached config is already available.

Prompt To Fix With AI
This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt
Line: 96-100

Comment:
**Deferred effects may never fire after a user switch**

When a user is switched (`Identify` reducer detects a different `appUserId`), `SdkState.Updates.FullResetOnIdentify` is dispatched, which sets `configReady = false`. The deferred effects block then waits `until = { it.configReady }`.

However, `configReady` is only set back to `true` when `ConfigManager` explicitly dispatches `SdkState.Updates.ConfigReady` — which only happens after an initial config retrieval or a `refreshConfiguration()` call. Since `FullResetOnIdentify` does **not** trigger a new config fetch in `ConfigManager`, and `completeReset()` only resets other managers (not ConfigManager), `configReady` may never flip back to `true` in the current session.

This means `IdentityEffect.ResolveSeed`, `IdentityEffect.FetchAssignments`, and `IdentityEffect.ReevaluateTestMode` would only run after the next periodic config refresh — a silent behavioural regression compared to the old `configManager.configState.awaitFirstValidConfig()` path, which found the already-cached config immediately.

Consider dispatching `ConfigReady` in `ConfigManager` explicitly after a reset, or reusing the existing cached config state to release deferred effects when the cached config is already available.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +51 to +57
withErrorTracking {
(event as Reducer<SdkState>).applyOn(fx, _state.value)
}.let { either ->
when (either) {
is Success -> either.value
is Failure -> _state.value // keep current state on error
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Unsafe cast — IdentityState.Updates dispatched directly would ClassCastException

The engine unconditionally casts every SdkEvent to Reducer<SdkState>. IdentityState.Updates extends Reducer<IdentityState>, not Reducer<SdkState>, so dispatching an IdentityState.Updates directly to engine.dispatch() would throw a ClassCastException at runtime (caught by withErrorTracking, causing silent state loss).

Nothing in the type system prevents engine.dispatch(IdentityState.Updates.Reset). Since Engine is internal and engine is exposed as internal val engine: Engine on IdentityManager, this is an easy mistake to make — the tests already call manager.engine.dispatch(SdkState.Updates.ConfigReady) directly, showing the pattern is expected.

Consider restricting Engine.dispatch to accept only SdkState.Updates (or a dedicated sealed RootEvent type) to make the constraint compile-time safe, rather than relying on a runtime cast suppressed with @Suppress("UNCHECKED_CAST").

Prompt To Fix With AI
This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/misc/primitives/Engine.kt
Line: 51-57

Comment:
**Unsafe cast — `IdentityState.Updates` dispatched directly would `ClassCastException`**

The engine unconditionally casts every `SdkEvent` to `Reducer<SdkState>`. `IdentityState.Updates` extends `Reducer<IdentityState>`, not `Reducer<SdkState>`, so dispatching an `IdentityState.Updates` directly to `engine.dispatch()` would throw a `ClassCastException` at runtime (caught by `withErrorTracking`, causing silent state loss).

Nothing in the type system prevents `engine.dispatch(IdentityState.Updates.Reset)`. Since `Engine` is `internal` and `engine` is exposed as `internal val engine: Engine` on `IdentityManager`, this is an easy mistake to make — the tests already call `manager.engine.dispatch(SdkState.Updates.ConfigReady)` directly, showing the pattern is expected.

Consider restricting `Engine.dispatch` to accept only `SdkState.Updates` (or a dedicated sealed `RootEvent` type) to make the constraint compile-time safe, rather than relying on a runtime cast suppressed with `@Suppress("UNCHECKED_CAST")`.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +52 to +53
private val engineDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val engineScope = CoroutineScope(engineDispatcher)
Copy link
Contributor

Choose a reason for hiding this comment

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

Thread executor is never closed — resource leak

Executors.newSingleThreadExecutor().asCoroutineDispatcher() creates a platform thread that is never shut down. The CoroutineDispatcher returned by asCoroutineDispatcher() implements Closeable, but no reference is kept to call .close() when the IdentityManager is no longer needed (e.g., after Superwall.reset()).

This leaks an OS thread for each IdentityManager instance created during the app's lifecycle.

Consider storing the executor reference and providing a teardown path, or using a lifecycle-scoped dispatcher:

private val engineExecutor = Executors.newSingleThreadExecutor()
private val engineDispatcher = engineExecutor.asCoroutineDispatcher()
// ... call engineExecutor.shutdown() / engineDispatcher.close() when tearing down
Prompt To Fix With AI
This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt
Line: 52-53

Comment:
**Thread executor is never closed — resource leak**

`Executors.newSingleThreadExecutor().asCoroutineDispatcher()` creates a platform thread that is never shut down. The `CoroutineDispatcher` returned by `asCoroutineDispatcher()` implements `Closeable`, but no reference is kept to call `.close()` when the `IdentityManager` is no longer needed (e.g., after `Superwall.reset()`).

This leaks an OS thread for each `IdentityManager` instance created during the app's lifecycle.

Consider storing the executor reference and providing a teardown path, or using a lifecycle-scoped dispatcher:
```kotlin
private val engineExecutor = Executors.newSingleThreadExecutor()
private val engineDispatcher = engineExecutor.asCoroutineDispatcher()
// ... call engineExecutor.shutdown() / engineDispatcher.close() when tearing down
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +34 to +48
fun log(
logLevel: LogLevel,
scope: LogScope,
message: String = "",
info: Map<String, Any>? = null,
error: Throwable? = null,
) {
Logger.debug(
logLevel,
scope,
message,
info,
error,
)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Fx.log() performs an immediate side effect inside the reducer

All other Fx methods add to pending for deferred execution. log() instead calls Logger.debug() directly, making reducers impure — they can perform I/O (logging) during the "pure reduce" phase that the Engine relies on being side-effect-free.

Additionally, Effect.Log is defined in Effects.kt but is never produced by Fx.log(), making it dead code.

If the intent is to keep reducers pure, Fx.log() should enqueue an Effect.Log rather than calling the logger eagerly:

fun log(...) {
    pending += Effect.Log(logLevel, scope, message, info, error)
}

And EffectRunner / Engine should handle Effect.Log in its when dispatch.

Prompt To Fix With AI
This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/misc/primitives/Fx.kt
Line: 34-48

Comment:
**`Fx.log()` performs an immediate side effect inside the reducer**

All other `Fx` methods add to `pending` for deferred execution. `log()` instead calls `Logger.debug()` directly, making reducers impure — they can perform I/O (logging) during the "pure reduce" phase that the `Engine` relies on being side-effect-free.

Additionally, `Effect.Log` is defined in `Effects.kt` but is never produced by `Fx.log()`, making it dead code.

If the intent is to keep reducers pure, `Fx.log()` should enqueue an `Effect.Log` rather than calling the logger eagerly:
```kotlin
fun log(...) {
    pending += Effect.Log(logLevel, scope, message, info, error)
}
```
And `EffectRunner` / `Engine` should handle `Effect.Log` in its `when` dispatch.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +62 to +65
IdentityLogic.sanitize(userId).takeIf { !it.isNullOrEmpty() }?.let { sanitized ->
if (sanitized.isEmpty()) {
return@let state
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Unreachable guard inside Identify reducer

The takeIf { !it.isNullOrEmpty() } predicate at the call site guarantees sanitized is non-null and non-empty before the .let lambda runs. The inner if (sanitized.isEmpty()) check is therefore dead code that can never be reached.

Suggested change
IdentityLogic.sanitize(userId).takeIf { !it.isNullOrEmpty() }?.let { sanitized ->
if (sanitized.isEmpty()) {
return@let state
}
IdentityLogic.sanitize(userId).takeIf { !it.isNullOrEmpty() }?.let { sanitized ->
if (sanitized == state.appUserId) return@let state
Prompt To Fix With AI
This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt
Line: 62-65

Comment:
**Unreachable guard inside `Identify` reducer**

The `takeIf { !it.isNullOrEmpty() }` predicate at the call site guarantees `sanitized` is non-null and non-empty before the `.let` lambda runs. The inner `if (sanitized.isEmpty())` check is therefore dead code that can never be reached.

```suggestion
                IdentityLogic.sanitize(userId).takeIf { !it.isNullOrEmpty() }?.let { sanitized ->
                    if (sanitized == state.appUserId) return@let state
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 363 to 367
When("reset is called not during identify") {
manager.reset(duringIdentify = false)
Thread.sleep(100)
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Thread.sleep produces flaky tests

Many tests synchronize on async engine operations using Thread.sleep(100) / Thread.sleep(200). Because IdentityManager creates a real Executors.newSingleThreadExecutor() dispatcher that the TestCoroutineScheduler cannot control, advanceUntilIdle() won't drain it — hence the sleeps. However, this makes tests non-deterministic: they can fail spuriously on slow CI machines or pass incorrectly on fast ones.

This applies to tests throughout both IdentityManagerTest and IdentityManagerUserAttributesTest.

Consider making the engine dispatcher injectable (e.g., a constructor parameter defaulting to the single-thread executor) so tests can pass UnconfinedTestDispatcher() or StandardTestDispatcher(testScheduler) and use advanceUntilIdle() / runCurrent() for reliable synchronisation without wall-clock sleeps.

Prompt To Fix With AI
This is a comment left during a code review.
Path: superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt
Line: 363-367

Comment:
**`Thread.sleep` produces flaky tests**

Many tests synchronize on async engine operations using `Thread.sleep(100)` / `Thread.sleep(200)`. Because `IdentityManager` creates a real `Executors.newSingleThreadExecutor()` dispatcher that the `TestCoroutineScheduler` cannot control, `advanceUntilIdle()` won't drain it — hence the sleeps. However, this makes tests non-deterministic: they can fail spuriously on slow CI machines or pass incorrectly on fast ones.

This applies to tests throughout both `IdentityManagerTest` and `IdentityManagerUserAttributesTest`.

Consider making the engine dispatcher injectable (e.g., a constructor parameter defaulting to the single-thread executor) so tests can pass `UnconfinedTestDispatcher()` or `StandardTestDispatcher(testScheduler)` and use `advanceUntilIdle()` / `runCurrent()` for reliable synchronisation without wall-clock sleeps.

How can I resolve this? If you propose a fix, please make it concise.

@ianrumac ianrumac closed this Mar 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant