fix: flush pending assignment and exposure events on LocalEvaluationClient.stop() - #79
fix: flush pending assignment and exposure events on LocalEvaluationClient.stop()#79cmyui wants to merge 1 commit into
Conversation
LocalEvaluationClient.stop() stopped the flag config poller and closed the connection pool, but never flushed or shut down the Amplitude analytics instances backing the assignment and exposure services. Any events still in their buffers (up to flush_queue_size per instance, accumulating for up to flush_interval_millis) were silently dropped unless the interpreter happened to exit cleanly enough for the analytics SDK's atexit hook to fire - which it often doesn't in forked/reaped server workers. stop() now flushes both instances, waits up to a configurable timeout (new parameter, default 10s, None = wait indefinitely) for the pending batches to send, then shuts the instances down. Instances shut down after the flush so late-tracked events are dropped deliberately rather than accumulating in a stopped client. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is ON, but it could not run because the branch was deleted or merged before autofix could start.
Reviewed by Cursor Bugbot for commit 41c2df7. Configure here.
sandeep-madugula
left a comment
There was a problem hiding this comment.
Reviewed the diff and ran the full unit test suite locally (python -m unittest discover -s ./tests -p '*_test.py', Python 3.11, note: the fork PR CI didn't run the Unit Test workflow).
- 157 tests pass — the only errors are the 4 pre-existing
setUpClassfailures from missingAPI_KEY/EU_API_KEYenvironment secrets, identical on unmodifiedmain. - I independently verified the Bugbot "nested futures" finding is a false positive for this repo's pinned dependency range (
amplitude_analytics~=1.1.1, i.e. 1.1.x; 1.1.5 installed):Workers.flush()returns a singleFutureorNone, soTimeline.flush()/Amplitude.flush()return a flatlist[Future | None]. Theif f is not Nonefilter handles it correctly, andwait()receives only real futures. Resolving that thread. - Ordering is safe:
flush()+ boundedwait()first, thenshutdown()— and in 1.1.x,Workers.stop()itself does a final flush and a blockingthreads_pool.shutdown(), which acts as an extra safety net for anything the timeout abandoned. - The behavior change (
stop()can now block up to 10s by default) is real but is the point of the fix, is documented in the docstring, andstop(timeout=0)restores near-fire-and-forget.__exit__picking up the same behavior is consistent.
Generated by Claude Code
sandeep-madugula
left a comment
There was a problem hiding this comment.
Revisiting my earlier approval after a closer look at the shutdown() step — requesting one change: flush the analytics instances, but don't shut them down.
The problem isn't the instances themselves (each LocalEvaluationClient constructs its own private Amplitude objects), it's the config object they share with the caller. Amplitude.shutdown() does:
def shutdown(self):
self.configuration.opt_out = True
self.__timeline.shutdown()and self.configuration here is the caller-provided AssignmentConfig/ExposureConfig (both subclass amplitude.Config and are passed straight into Amplitude(api_key, config)). Setting opt_out = True on it outlives this client:
- A new
LocalEvaluationClientconstructed with the same config object silently drops every assignment/exposure event — no error, no log (opt_outshort-circuits inTimeline.process). - A customer's own
Amplitudeinstance sharing that config object gets silently opted out too. stop()→start()on the same client (which the API permits) leaves tracking permanently dead.
Dropping the shutdown() loop costs nothing relative to main: resource teardown reverts to exactly today's behavior (the analytics SDK's own exit hook), and the delivered-events fix — the real point of this PR — is entirely the flush() + bounded wait().
Concretely: remove the instance.shutdown() loop (and the shutdown assertions in the tests), and perhaps rename __shutdown_event_services → __flush_event_services.
Since we'd like to land this fix promptly, I've also opened #80 with that flush-only variant, adapted from this PR with credit to you. Happy to go with either — if you'd rather update this PR, I'll close #80 in its favor.
Thanks again for the thorough diagnosis and the verification work on the flush return shape — that all checked out.
Generated by Claude Code
…lient.stop() (#80) stop() previously stopped the flag poller and closed the connection pool but never drained the assignment/exposure Amplitude buffers, silently dropping up to flush_queue_size events per instance whenever the analytics SDK's atexit hook doesn't fire (recycled workers, forked job runners, SIGKILL'd containers). stop() now flushes both instances and waits for the returned futures with a bounded timeout (new parameter, default 10s, None waits forever), logging a warning if batches remain. The instances are deliberately NOT shut down: Amplitude.shutdown() sets opt_out=True on the caller-provided AssignmentConfig/ExposureConfig, which outlives the client and silently drops all events for any instance reusing that config. Flush-only alternative to #79. Claude-Session: https://claude.ai/code/session_01FQQHvcxnQ1Qvo6CaQuwKGn Co-authored-by: Claude <noreply@anthropic.com>

Problem
LocalEvaluationClient.stop()stops the flag config poller and closes the connection pool, but never touches theAmplitudeanalytics instances created for the assignment and exposure services. Events still sitting in their buffers — up toflush_queue_sizeper instance, accumulating for up toflush_interval_millis(defaults: 200 events / 10s) — are silently dropped.The analytics SDK does register an atexit shutdown hook, but that only fires on a clean interpreter exit of the main thread. In the environments where server-side local evaluation typically runs (gunicorn/uwsgi workers being recycled, forked job runners, containers receiving SIGKILL after a grace period), that hook frequently never runs — so the tail of assignment/
$exposureevents for every worker lifecycle is lost.stop()/ the context-manager__exit__is the documented lifecycle point, and it should deliver what was tracked.Fix
stop()now, after stopping the poller and closing the connection pool:flush()on the assignment and exposureAmplitudeinstances (public API) and collects the returned futures.timeoutparameter, default 10 seconds, consistent with the SDK's other network timeout defaults.Nonewaits indefinitely. On timeout, a warning is logged with the number of unsent batches.shutdown()on both instances — after the flush, so events tracked post-stop()are dropped deliberately rather than accumulating in a stopped client.Clients with no assignment/exposure config are unaffected.
Behavior change
stop()previously returned immediately; it can now block up totimeoutseconds performing network sends. That is the point of the fix, but it is a change — flagging it for review.stop(timeout=0)effectively restores fire-and-forget (flush is still triggered; the wait is skipped).Tests
tests/local/stop_flush_test.py: flush-then-shutdown ordering on both services, timeout bounding with a never-completing future, no-op with no event services, and context-manager exit. The 4 pre-existing errors intests/util/user_test.pyoccur identically on unmodifiedmainin my environment.🤖 Generated with Claude Code
Note
Medium Risk
Changes documented client lifecycle semantics and can block up to 10s on shutdown; behavior is intentional but may affect worker recycle timing in production.
Overview
LocalEvaluationClient.stop()now drains buffered assignment and exposure analytics before tearing down, instead of only stopping the flag poller and closing the HTTP pool.After the existing shutdown steps, it **
flush()**es each configured assignment/exposureAmplitudeinstance, **wait()**s on returned futures up to a newtimeoutargument (10s default;None= wait forever), logs a warning if batches remain, then callsshutdown()on those instances.__exit__still callsstop(), so context-manager use gets the same behavior. Clients without assignment/exposure config are unchanged.stop()may now block on network I/O (up totimeout);stop(timeout=0)still triggers flush but skips waiting.Adds
tests/local/stop_flush_test.pyfor flush/shutdown ordering, timeout bounding, no-op without event services, and context-manager exit.Reviewed by Cursor Bugbot for commit 41c2df7. Bugbot is set up for automated code reviews on this repo. Configure here.