Skip to content

fix: flush pending assignment and exposure events on LocalEvaluationClient.stop() - #79

Closed
cmyui wants to merge 1 commit into
amplitude:mainfrom
cmyui:flush-event-services-on-stop
Closed

fix: flush pending assignment and exposure events on LocalEvaluationClient.stop()#79
cmyui wants to merge 1 commit into
amplitude:mainfrom
cmyui:flush-event-services-on-stop

Conversation

@cmyui

@cmyui cmyui commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Problem

LocalEvaluationClient.stop() stops the flag config poller and closes the connection pool, but never touches the Amplitude analytics instances created for the assignment and exposure services. Events still sitting in their buffers — up to flush_queue_size per instance, accumulating for up to flush_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/$exposure events 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:

  1. Calls flush() on the assignment and exposure Amplitude instances (public API) and collects the returned futures.
  2. Waits for them with a bounded timeout — new timeout parameter, default 10 seconds, consistent with the SDK's other network timeout defaults. None waits indefinitely. On timeout, a warning is logged with the number of unsent batches.
  3. Calls 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 to timeout seconds 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 in tests/util/user_test.py occur identically on unmodified main in 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/exposure Amplitude instance, **wait()**s on returned futures up to a new timeout argument (10s default; None = wait forever), logs a warning if batches remain, then calls shutdown() on those instances. __exit__ still calls stop(), so context-manager use gets the same behavior. Clients without assignment/exposure config are unchanged.

stop() may now block on network I/O (up to timeout); stop(timeout=0) still triggers flush but skips waiting.

Adds tests/local/stop_flush_test.py for 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.

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>
@cmyui
cmyui requested a review from a team as a code owner August 4, 2026 17:04

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

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.

Comment thread src/amplitude_experiment/local/client.py

@sandeep-madugula sandeep-madugula left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 setUpClass failures from missing API_KEY/EU_API_KEY environment secrets, identical on unmodified main.
  • 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 single Future or None, so Timeline.flush()/Amplitude.flush() return a flat list[Future | None]. The if f is not None filter handles it correctly, and wait() receives only real futures. Resolving that thread.
  • Ordering is safe: flush() + bounded wait() first, then shutdown() — and in 1.1.x, Workers.stop() itself does a final flush and a blocking threads_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, and stop(timeout=0) restores near-fire-and-forget. __exit__ picking up the same behavior is consistent.

Generated by Claude Code

@sandeep-madugula sandeep-madugula left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 LocalEvaluationClient constructed with the same config object silently drops every assignment/exposure event — no error, no log (opt_out short-circuits in Timeline.process).
  • A customer's own Amplitude instance 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

sandeep-madugula added a commit that referenced this pull request Aug 18, 2026
…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>
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