fix(analytics): thread-safe polling start + surface fetch errors (SDK-81, SDK-78)#155
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #155 +/- ##
==========================================
- Coverage 97.11% 96.98% -0.13%
==========================================
Files 15 15
Lines 762 764 +2
==========================================
+ Hits 740 741 +1
- Misses 22 23 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Pushed P1 — concurrent stop+start race ( P2 — fragile All 48 specs pass locally. |
There was a problem hiding this comment.
Pull request overview
This PR hardens Mixpanel::Flags::LocalFlagsProvider’s polling loop by making polling thread startup/shutdown thread-safe and by ensuring polling fetch failures are visible even when no custom error_handler is configured.
Changes:
- Add a mutex + per-thread stop signal to prevent duplicate/orphaned polling threads under concurrent
start_polling_for_definitions!calls. - Add
log_polling_errorto alwayswarnto stderr before delegating to@error_handler. - Add specs covering concurrent start idempotency, stop+start behavior, and stderr warning behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| lib/mixpanel-ruby/flags/local_flags_provider.rb | Adds mutex-guarded polling lifecycle + unconditional stderr logging for polling errors. |
| spec/mixpanel-ruby/flags/local_flags_spec.rb | Adds regression tests for concurrency/idempotency and for surfacing polling errors to stderr. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…tch failure Two SDK-78 concerns addressed against master's current polling architecture (PR #149's ConditionVariable + lifecycle-mutex design already covers the original SDK-81 concurrent-start concerns, so those commits from an earlier iteration of this PR are dropped as superseded). - safe_handle_error now warns to stderr unconditionally in addition to dispatching to @error_handler. Prior to this, callers without an explicit error_handler saw no signal for schema drift (NoMethodError, JSON::ParserError, etc.) — the default Mixpanel::ErrorHandler#handle is a no-op, so the polling loop would swallow every failure forever. Matches the convention across mixpanel-python / mixpanel-java / mixpanel-go / mixpanel-node. - start_polling_for_definitions! wraps just the initial fetch in an inner begin/rescue. A transient initial-fetch failure (network blip, HTTP 500) previously fell through to the method-level rescue and returned before the @lifecycle_mutex block ever spawned the poller — a single startup blip left the SDK permanently without polling until the caller manually retried. Now the loop still spawns and retries on the configured interval.
7804025 to
ffd1980
Compare
|
Salvaged this PR against current master. Since PR #149 refactored the polling architecture entirely ( Force-pushed: What survives:
What was dropped (superseded by master PR #149):
Tests: 52/52 in The PR title still says "thread-safe polling start + surface fetch errors (SDK-81, SDK-78)" but the SDK-81 half is no longer in this PR since master already fixes it. Happy to retitle if you'd prefer. |
Bundles two small audit fixes in the polling loop — both
local_flags_provider.rb, both touching the samebegin/rescueblock.SDK-81 — thread-safe polling start
start_polling_for_definitions!had a check-then-act race:if @config[:enable_polling] && !@polling_threadran unsynchronized, so two concurrent callers could both see@polling_thread == nil, both assignThread.new { ... }, and the earlier thread would become orphaned but keep polling forever.Add a
@polling_mutexguarding the polling-thread lifecycle:start_polling_for_definitions!: under the mutex, bail early if@polling_threadalready exists, otherwise spawn the thread.stop_polling_for_definitions!: snapshot@polling_threadand@stop_pollingunder the mutex, thenjoinoutside the mutex so a long-running join can't block a concurrentstart!for a full poll interval.SDK-78 — surface polling-loop errors
The loop caught
StandardErrorand dispatched only to@error_handler, whose defaultMixpanel::ErrorHandler#handleis a no-op. Schema drift (NoMethodError,JSON::ParserError,TypeError, …) looped forever undetected unless the user had configured a custom handler.Add a
log_polling_errorhelper that alwayswarns to$stderrbefore dispatching to@error_handler. Visibility no longer depends on configuration. Matches the de facto convention across every other Mixpanel SDK polling loop (mixpanel-pythonlogger.exception, mixpanel-javalogger.log(WARNING), mixpanel-golog.Printf, mixpanel-nodethis.logger?.error) — all log unconditionally and keep polling. Crashing the polling thread on the first bad payload would freeze local evaluation at whatever variants were last cached — strictly worse than continuing.Context
Linear: SDK-81, SDK-78. Same audit-driven cross-SDK push; for SDK-81, Ruby is one of three affected (also Java #91, Node #278). SDK-78 was Ruby-only — the other SDKs already log unconditionally.
Test plan
"is idempotent under concurrent start calls and does not leak threads"(SDK-81) spins up 8 contender threads behind aQueuegate; asserts only 1 new background thread exists after they all return. Before the fix the count would be 8."warns to stderr when fetch raises and no error_handler is configured"(SDK-78) builds the provider withnilerror_handler + 500 response, asserts thewarnlands on stderr."surfaces unexpected errors (schema drift) instead of swallowing them silently"(SDK-78) injectsNoMethodErrorviaallow(...).to receive(:fetch_flag_definitions), asserts both the error_handler dispatch AND the stderr warning fire.🤖 Generated with Claude Code