Skip to content

fix: wait for pending user updates before evaluating targeting [ENG-1817] - #56

Merged
Dhruwang merged 2 commits into
mainfrom
anshuman/eng-1817-ios-sdk-wait-for-pending-attribute-updates-before-evaluating
Sep 15, 2026
Merged

Dhruwang merged 2 commits into
mainfrom
anshuman/eng-1817-ios-sdk-wait-for-pending-attribute-updates-before-evaluating

Conversation

@pandeymangg

@pandeymangg pandeymangg commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Ref ENG-1817

What & why

Was: setAttribute("plan", "pro") immediately followed by track("...") evaluated targeting against the segment membership the contact had before the write, so the survey the host was trying to trigger did not show. Attribute writes are debounced ~0.5s, while track() read the cached filteredSurveys straight away. A first setUserId was worse — the id is not persisted until the response lands, so the contact still looked anonymous and every segment-targeted survey was dropped.

Now: track() waits for the queued update to land, then picks a survey. If the update fails, surveys with segment filters are skipped rather than shown on stale membership; surveys without them are unaffected.

Reported by the SJ app team. Mirrors what the JS SDK's command queue already does for CommandType.GeneralAction.

The wait is callback-based, not blocking: the debounce Timer is installed on the main run loop and track() is normally called from the main thread, so a semaphore there would stall the run loop the timer needs and guarantee a timeout.

Where to look

  • UpdateQueue.waitForPendingWork — the wait, its anonymous-user short circuit, and the timeout.
  • UpdateQueue.commit / syncDidFinish — values now move out of the queue, plus the in-flight guard.
  • SurveyManager.track / evaluate — the split, and why the isShowingSurvey latch is claimed before the wait.
Three further bugs this had to fix first
  • Mid-flight writes were silently lost. Values stayed queued during a request and the success path cleared them wholesale, so the later commit sent nothing. commit() now moves its values out; a failure hands them back.
  • commit() had no in-flight guard — only requestUserStateRefresh did — so a setAttribute during a request fired a second, racing POST /user. Now deferred and re-armed.
  • The move left the follow-up without an identity. syncDidFinish restores the user id for it. Caught by testAttributeSetDuringAnInFlightSyncIsStillSent against my own first cut.

A failure deliberately does not re-arm the debounce timer — self-retrying would turn a dead network into a request every half second, and UserManager.scheduleSyncRetry() already owns that backoff.

Coverage

Behaviour Level
setUserId()track() back-to-back shows a segment-targeted survey manual — simulator, local workspace, 1.5s latency injected into POST /user
Wait short-circuits when nothing is queued, and for an anonymous user unit (guard)
Rapid writes flush at once and coalesce into one request unit (guard)
Failed update reports failure to the caller unit (guard)
Mid-flight write survives and gets its own request unit (mutation) — drop the else if hasNewWork branch in UpdateQueue.syncDidFinish to turn it red
No second concurrent sync while one is in flight unit (mutation) — remove the guard !isSyncInFlight in UpdateQueue.commit

Rerun: xcodebuild test -scheme FormbricksSDK -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -only-testing:FormbricksSDKTests/UpdateQueueTests

Full suite: 120 tests, 0 failures.

Open gaps

No unit (red on main) row: waitForPendingWork and syncDidFinish(success:) do not exist on main, so these tests cannot compile against it. The end-to-end case is covered manually instead — the automated version needs SurveyManager driven with a seeded workspace and a mock present manager, and is not in this PR.

The race is masked on a loopback server. track() spends ~700ms in a fresh NWPathMonitor before it evaluates anything, while a 50ms localhost POST /user lands in ~550ms including the debounce — so the update wins by accident and the bug is invisible. Reproducing it needs latency injected into the route (1.5s was enough); against a real server the update loses on its own.

Worth stating plainly: for the setAttribute path this PR makes the ordering deterministic rather than fixing a reliably-reproducible failure. The setUserId path does fail outright — the id is not persisted until the response lands, so the contact reads as anonymous until then and every segment-targeted survey is dropped.

Still unverified: the failure path (server down → segment-targeted survey skipped, unsegmented one still shows), coalescing into a single request, and the double-track latch.

A second bug the first commit introduced

Releasing the waiter before filterSurveys() let it read the survey list computed from the previous user state, so setUserIdtrack() still showed nothing: the wait worked, and everything after it read stale data anyway. filterSurveys() runs first now, which also gives the main queue a happens-before edge on the write.

Found by manual testing, not by the unit tests — they never exercise that ordering. Related: filteredSurveys has no synchronisation at all, written on URLSession's background queue and read on the main queue.

Breaking changes

  • This is a breaking change

No public API changes. track() keeps its signature; it can now take longer to return control before a survey appears, bounded by Config.User.pendingUpdateTimeoutInSeconds (5s).


Agent: claude-opus-5 (Claude Code), reasoning unknown.

…817]

`setAttribute(...)` immediately followed by `track(...)` evaluated targeting
against the segment membership the contact had *before* the write. Attribute
updates are debounced ~0.5s while `track()` read the cached `filteredSurveys`
straight away, so the survey the host was trying to trigger did not show. A
first `setUserId` was worse: the id is not persisted until the response lands,
so `filterSurveys()` still saw an anonymous user and dropped every
segment-targeted survey.

`track()` now resolves the action class first — that comes from workspace
state, so a typo'd action still fails fast without a round trip — then waits
for the queue to settle before picking a survey. If the update did not land,
surveys with segment filters are skipped rather than shown on stale
membership; surveys without them are unaffected. Mirrors what the JS SDK's
command queue does for `CommandType.GeneralAction`.

The wait is callback-based, not blocking. The debounce `Timer` lives on the
main run loop and `track()` is normally called from the main thread, so a
semaphore there would stall the run loop the timer needs and guarantee a
timeout.

Three further bugs had to be fixed for the wait to mean anything:

- Writes made while a request was out were silently lost. The values stayed
  queued during the request and the success path cleared them wholesale, so
  the later commit sent nothing. `commit()` now moves its values out of the
  queue, and a failure hands them back.
- `commit()` had no in-flight guard — only `requestUserStateRefresh` did — so
  a mid-flight `setAttribute` fired a second, racing `POST /user`. It is now
  deferred and re-armed once the first settles.
- After that move the queue no longer held the user id, leaving a follow-up
  commit without an identity. `syncDidFinish` restores it for the follow-up.

A failure deliberately does not re-arm the debounce timer: retrying itself
would turn a dead network into a request every half second, and
`UserManager.scheduleSyncRetry()` already owns that backoff.
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: a6295740-b6cd-416f-9d16-034298a3483f


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The wait held `track()` until the queued update landed, and then evaluated
against the survey list computed from the user state that predated it — so a
`setUserId` immediately followed by `track()` still showed nothing, which is
the bug the wait was added to fix.

`syncDidFinish` resolves its waiters onto the main queue while `syncUser`'s
completion runs on URLSession's background queue, so releasing a waiter before
`filterSurveys()` let it read `filteredSurveys` while the re-filter was still
running. `filterSurveys()` never won that race either: the `displays` getter
decodes JSON out of UserDefaults on every access, so it lost reliably rather
than intermittently.

Re-filter first, release the waiters after. That also gives the main queue a
happens-before edge on the write, which matters because `filteredSurveys` has
no synchronisation of its own.
@sonarqubecloud

Copy link
Copy Markdown

@Dhruwang
Dhruwang added this pull request to the merge queue Sep 15, 2026
Merged via the queue into main with commit 94a2e9a Sep 15, 2026
4 checks passed
@pandeymangg pandeymangg mentioned this pull request Sep 16, 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.

2 participants