Skip to content

fix(flags): resolve queued flag reloads against a request that went out - #737

Open
posthog[bot] wants to merge 9 commits into
mainfrom
posthog-code/flags-pending-reload-callbacks
Open

fix(flags): resolve queued flag reloads against a request that went out#737
posthog[bot] wants to merge 9 commits into
mainfrom
posthog-code/flags-pending-reload-callbacks

Conversation

@posthog

@posthog posthog Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

An iOS app that overrides person properties per flag request (setPersonPropertiesForFlags) can read the wrong flag value for the first few hundred milliseconds after launch, then see it flip once a later /flags response lands. The original customer report turned out to be correct SDK behaviour (request-time overrides do beat stored person properties), but investigating it surfaced a real defect underneath.

Three code paths can each start a /flags request at startup — the automatic preload off /config (config.preloadFeatureFlags, on by default), identify(), and the app's own reloadFeatureFlags() — and none of them is ordered against the app's setPersonPropertiesForFlags() call. loadFeatureFlags kept only one pending slot: when a third reload arrived it overwrote the queued request and resolved that caller's completion handler immediately with getCachedFeatureFlags() — disk-cached, pre-override values from a request that never went out. There was also no completion handler on setPersonPropertiesForFlags, so there was no supported way to wait for "flags reloaded with my overrides".

Anything combining request-time person properties with identify() and reloadFeatureFlags() is exposed; a launch-time flag read can silently return the pre-override value.

Changes

  • PostHogRemoteConfig.loadFeatureFlags — the pending slot now holds a list of completion handlers. Later callers still overwrite the request parameters (newest identity/groups win), but every displaced caller is carried over and resolved against the coalesced request that actually goes out, instead of against the disk cache.
  • New setPersonPropertiesForFlags(_:reloadFeatureFlags:completion:) — lets apps await a /flags response evaluated with their overrides. The existing overloads delegate to it, so their behaviour is unchanged.
  • Docs — the startup ordering contract is now spelled out on both the new method and PostHogConfig.preloadFeatureFlags: the automatic preload is not ordered against app-set overrides, and reset() clears them.
  • PostHogRemoteConfig.reloadFeatureFlags no longer drops the callback on the canReloadFlagsForTesting early return, so a completion handler can never be silently dropped.

💚 How did you test it?

Added a Test concurrent flag reload coalescing suite in PostHogTests/PostHogFeatureFlagsTest.swift. The /flags stub only enables override-flag when the request body actually carried the override, so a callback resolved against a real response is distinguishable from one resolved against the cache. It covers:

  • a reload displaced from the pending slot resolving against the coalesced request rather than the disk cache;
  • the reported sequence — setup → setPersonPropertiesForFlagsidentifyreloadFeatureFlags with preloadFeatureFlags on and /config resolving mid-flight — reading the override-aware value (this fails on main);
  • the new completion handler resolving only after a response carrying the properties.

Not verified locally: this environment has no Swift/Xcode toolchain, so make lint, make test and make buildSdk were not run — CI is the first execution of these tests. api/posthog-ios.public-api.txt was hand-edited for the new public method rather than regenerated with make apiUpdate; if the public-api job disagrees, regenerate it.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file (added .changeset/flags-pending-reload-callbacks.md by hand — no pnpm in this environment)

🤖 Agent context

Autonomy: Fully autonomous

Written by Claude Code (PostHog Desktop) from an inbox report. The report's own prescription — hold completion handlers in a list, add a completion handler to setPersonPropertiesForFlags, document the ordering contract — was followed as-is.

Two alternatives were considered and rejected as riskier than the symptom: suppressing the notification for a /flags response that's already known to be stale (would delay didReceiveFeatureFlags and extend session-replay buffering), and declining to cache a response computed with superseded person properties (would leave flags unavailable for longer, and on a first launch there's nothing to fall back to). Neither is needed to close the hole the report describes; both change flag-availability timing for every app.

A subagent reviewed the diff for compile and behavioural regressions since nothing could be built here; its findings on the stale public-API snapshot, the dropped callback on the canReloadFlagsForTesting path, and the missing changeset are folded in.


Created with PostHog Desktop from this inbox report.

`loadFeatureFlags` kept only one pending `/flags` reload while another was in
flight. A third reload overwrote the queued one and immediately resolved that
caller's completion handler with `getCachedFeatureFlags()` — disk-cached flags
that never saw the caller's request-time person properties.

The pending slot now keeps every displaced caller's completion handler and
resolves them all against the coalesced request that actually goes out.

Adds `setPersonPropertiesForFlags(_:reloadFeatureFlags:completion:)` so apps
can await flags evaluated with their overrides, and documents the startup
ordering contract on it and on `PostHogConfig.preloadFeatureFlags`.

Generated-By: PostHog Code
Task-Id: 5406b58a-1a76-4d8b-820a-94cd7c1f138b
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-08-19 17:33:42 UTC
Duration: 208080ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 2816ms
Format Validation.Event Has Uuid 312ms
Format Validation.Event Has Lib Properties 311ms
Format Validation.Distinct Id Is String 301ms
Format Validation.Token Is Present 373ms
Format Validation.Custom Properties Preserved 2751ms
Format Validation.Event Has Timestamp 2765ms
Retry Behavior.Retries On 503 11247ms
Retry Behavior.Does Not Retry On 400 4771ms
Retry Behavior.Does Not Retry On 401 4752ms
Retry Behavior.Respects Retry After Header 7737ms
Retry Behavior.Implements Backoff 21708ms
Retry Behavior.Retries On 500 8674ms
Retry Behavior.Retries On 502 8840ms
Retry Behavior.Retries On 504 5250ms
Retry Behavior.Max Retries Respected 21352ms
Deduplication.Generates Unique Uuids 2917ms
Deduplication.Preserves Uuid On Retry 5394ms
Deduplication.Preserves Uuid And Timestamp On Retry 10408ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7757ms
Deduplication.No Duplicate Events In Batch 2857ms
Deduplication.Different Events Have Different Uuids 2643ms
Compression.Sends Gzip When Enabled 2822ms
Batch Format.Uses Proper Batch Structure 2755ms
Batch Format.Flush With No Events Sends Nothing 328ms
Batch Format.Multiple Events Batched Together 2890ms
Error Handling.Does Not Retry On 403 4767ms
Error Handling.Does Not Retry On 413 4631ms
Error Handling.Retries On 408 9238ms

Feature_Flags Tests

16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 2689ms
Request Payload.Flags Request Uses V2 Query Param 2687ms
Request Payload.Flags Request Hits Flags Path Not Decide 2773ms
Request Payload.Flags Request Omits Authorization Header 2744ms
Request Payload.Token In Flags Body Matches Init 2733ms
Request Payload.Groups Round Trip 2766ms
Request Payload.Groups Default To Empty Object 2733ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It 2775ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 2715ms
Request Payload.Disable Geoip Omitted Defaults To False 2778ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 2822ms
Request Lifecycle.No Flags Request On Init Alone 68ms
Request Lifecycle.No Flags Request On Normal Capture 2786ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 5327ms
Request Lifecycle.Mock Response Value Is Returned To Caller 2828ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 2884ms

@marandaneto

Copy link
Copy Markdown
Member

@ioannisj @turnipdabeets i think this needs a bit of testing/thinking, likely on android as well, i'd leave that up to you both since its the only sdks that would have this issue i guess?

@github-actions

Copy link
Copy Markdown
Contributor

This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, post a comment or remove the stale label – otherwise this will be closed in another week.

@github-actions github-actions Bot added the stale label Aug 13, 2026
@marandaneto
marandaneto marked this pull request as ready for review August 18, 2026 13:40
@marandaneto
marandaneto requested a review from a team as a code owner August 18, 2026 13:40
The queued-reload rework in 1618c83 preserved each caller's evaluation context
by snapshotting person properties at enqueue time and appending one pending
request per context. Measured against main, that regressed two ordinary paths:

- every setPersonPropertiesForFlags call was treated as order-sensitive,
  including the overloads that pass a no-op completion, so a burst of three
  issued four /flags requests instead of two, each carrying a partial
  property set;
- a queued reload no longer picked up properties set while it waited and went
  out with no person_properties at all, reachable from setPersonProperties()
  on a plain launch, which is the failure class this branch exists to fix.

What it bought was narrower: a caller awaiting one value for a key while a
later caller overwrote that same key inside the same coalescing window. Person
properties merge additively, so the single-slot form already guarantees a
caller sees a response evaluated with at least its own properties.

Revert to the single pending slot holding a list of completion handlers, and
keep reading person properties when the request is sent. The lock-order fix in
getPersonPropertiesForFlags() is kept, with both lock orders named in a comment.
# Conflicts:
#	PostHog/PostHogRemoteConfig.swift
…aviour

The first test fails on 1618c83 (override-flag comes back false, the queued
reload having gone out with no person_properties) and passes on the single-slot
form, so it pins the regression that commit removed rather than restating it.

The second covers the documented behaviour that a reload completion handler runs
even when the request fails, since the handler takes no parameter and cannot
report it.

Also ignores xcodebuild-ios.log, which the testOniOSSimulator target tees into
the working tree on every local run.
setPersonPropertiesForFlags(_:reloadFeatureFlags:completion:) was convenience,
not capability. Once queued reloads stop resolving from the disk cache, the
existing API already gives the same guarantee:

    setPersonPropertiesForFlags(props, reloadFeatureFlags: false)
    reloadFeatureFlags { /* evaluated with props */ }

which is what the "setting properties then reloading" test exercises. Against
that, the overload was permanent Swift and Objective-C surface with a selector
inconsistent with its sibling, no counterpart on Android or React Native, and a
() -> Void handler that cannot report the quota-limited and error paths where it
still fires with stale cached flags.

The startup ordering contract it documented is worth keeping, so it moves onto
setPersonPropertiesForFlags(_:reloadFeatureFlags:) and points at the two-call
form. api/posthog-ios.public-api.txt is back to matching main: no public API
change, so the changeset drops to patch.
@turnipdabeets
turnipdabeets force-pushed the posthog-code/flags-pending-reload-callbacks branch from 744e892 to ed8c39c Compare August 18, 2026 15:50
Drops the callback?(nil) added to the canReloadFlagsForTesting guard: that flag
is never set false outside tests, no test awaits a handler under it, and main
does not resolve the caller there either, so the line was speculative hardening
on a path the fix does not touch.

The rest is comment and test noise: numbered narration that restated the code,
a flagsRequests reset left over from an out-of-tree probe harness where suites
shared a server, and a `resolved` flag asserting something the continuation
returning already proves.
@turnipdabeets
turnipdabeets removed their request for review August 19, 2026 16:35
Five independent reviews of this branch. Four findings were worth acting on.

The lock-order comment and the changeset named a cycle nobody could reach.
Three reviewers analysed it and proposed three different A-sides; the one that
searched hardest found none at all, since loadCachedPropertiesForFlags() runs
inside init, before the instance escapes, so no other thread can hold that
per-instance lock. The code change stands on its own — do not call an escaping
closure that reaches setupLock while holding this lock — so both now say that
instead of asserting a deadlock this branch cannot demonstrate.

The new tests built their coalescing window out of wall-clock delays, and a
reviewer running CI's own command watched them fail twice in a row, resolving in
3ms with the window never formed. The macOS job runs without retries, so that is
a red build. The first /flags response is now held on a semaphore the test
signals after issuing the reloads that must coalesce behind it, which makes the
window a fact rather than a race, and drops the suite from 1.8s to 1.1s.

Assertions on server.flagsRequests.count were contaminated by requests bleeding
from earlier tests — two reviewers observed 4 where the test expected 2, for
identical code. They now assert on the request bodies, which is what the tests
were actually about. The failure test asserted a flag that was never cached, so
it could not fail; it now seeds a flag first and checks it survives, covering
the reload spec's "failure keeps existing cached flags" scenario.

loadFeatureFlags took a non-optional callback that the survey-coalescing branch
silently discarded, safe only by a contract established a hundred lines away.
Three reviewers independently flagged it as the last place a completion can
vanish. The parameter is optional now, so that branch provably has nothing to
drop, and the eight internal reload sites stop pushing no-op closures into the
pending slot.

Also drops "guaranteed" from the public docs, which contradicted the failure
note fifteen lines below it, stops leaking /config as an implementation detail,
and records the latency change in the changeset: a displaced handler now waits
for a round trip plus retries where it used to resolve almost immediately.
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