Skip to content

fix(openfeature): add bounded invocation-end EVP flush - #21135

Open
vjfridge wants to merge 3 commits into
mainfrom
vickie/FFL-3412/lambda-telemetry-flush
Open

vjfridge wants to merge 3 commits into
mainfrom
vickie/FFL-3412/lambda-telemetry-flush

Conversation

@vjfridge

@vjfridge vjfridge commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

A customer asked about flushing feature-flag telemetry from Python Lambda functions (FFL-3412). Short invocations can finish before background writers send evaluation and exposure events; Lambda freezing can delay delivery, and termination can lose buffered events.

This PR provides the tracer capability that lets the Lambda wrapper flush at invocation end, without customers adding handler code or shutting down providers needed by warm invocations.

Changes

  • Add a versioned internal flush operation covering all active evaluation and exposure writers, including queued and in-flight events.
  • Bound the caller's wait and distinguish completed, failed, and timed-out delivery.
  • Coordinate periodic and invocation-end sends without adding retries or duplicate sends; recover after aggregation failures.
  • Keep providers reusable across warm invocations and reset coordination state after a fork.

Delivery remains best effort. Timeout stops waiting, not an outstanding send; the implementation adds writer synchronization and one reusable background coordinator.

Testing

  • After merging main: 269 tracer tests and 361 Lambda integration/wrapper tests plus 15 subtests passed against the rebuilt tracer; full lint checks passed.
  • Earlier local Lambda emulator smoke tests covered Agent/direct delivery, handler errors, timeouts, and warm recovery. These used local receivers and pre-merge builds.
  • Still outstanding: deployed AWS freeze/thaw and Datadog ingestion validation; I don't have access to the sandbox.

Release coordination

Companion: datadog-lambda-python #872. Either PR can merge first; release the tracer capability before the Lambda library/layer containing both changes. Record the supported versions before announcing support.

Track pending, dequeued, and in-flight feature flag telemetry and expose a versioned internal boundary for Lambda cleanup.

Environment: Datadog workspace

Co-Authored-By: Codex GPT-6 <noreply@localhost>
@cit-pr-commenter-54b7da

Copy link
Copy Markdown

Codeowners resolved as

Resolved from the full PR diff against main using the target branch CODEOWNERS file.
CODEOWNERS team requests not listed below are not required by the current file set.

ddtrace/internal/openfeature/README.md                                  @DataDog/feature-flagging-and-experimentation-sdk
ddtrace/internal/openfeature/_flagevaluation_writer.py                  @DataDog/feature-flagging-and-experimentation-sdk
ddtrace/internal/openfeature/_telemetry.py                              @DataDog/feature-flagging-and-experimentation-sdk
ddtrace/internal/openfeature/writer.py                                  @DataDog/feature-flagging-and-experimentation-sdk
releasenotes/notes/fix-openfeature-lambda-flush-6dd05d039855ce76.yaml   @DataDog/apm-python
tests/internal/test_serverless.py                                       @DataDog/apm-serverless @DataDog/asm-python @DataDog/serverless-aws @DataDog/serverless-azure-and-gcp
tests/openfeature/test_telemetry_flush.py                               @DataDog/feature-flagging-and-experimentation-sdk

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Dependency direction analysis

⚠️ Existing dependency direction violations

There are 133 dependency direction violations that already exist on the base branch and have not been changed by this PR.

Show existing violations (showing 5 of 133 highest severity)
ddtrace.internal.tracemethods -×-> ddtrace.trace  (internal-core -> product:tracing, score=120)
ddtrace.llmobs._integrations.openai_agents -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=118)
ddtrace.internal.ci_visibility.git_client -×-> ddtrace.trace  (product:ci_visibility -> product:tracing, score=118)
ddtrace.llmobs._integrations.claude_agent_sdk -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=118)
ddtrace.internal.ci_visibility.api._base -×-> ddtrace.trace  (product:ci_visibility -> product:tracing, score=118)

To see all violations, download the layers-base.json and layers-pr.json artifacts from this CI job and run:

uv run --script scripts/import-analysis/layers.py compare layers-base.json layers-pr.json

@pr-commenter

pr-commenter Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-10-23 02:48:14

Comparing candidate commit 2391a8e in PR branch vickie/FFL-3412/lambda-telemetry-flush with baseline commit 92c1fb2 in branch main.

📊 Benchmarking dashboard

Found 0 performance improvements and 3 performance regressions! Performance is the same for 17 metrics, 0 unstable metrics.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

scenario:openfeatureflagevaluation-hook-enqueue-protected-typical

  • 🟥 execution_time [+2.522µs; +2.617µs] or [+19.547%; +20.282%]

scenario:openfeatureflagevaluation-hook-enqueue-scale

  • 🟥 execution_time [+2.748µs; +2.933µs] or [+10.212%; +10.901%]

scenario:openfeatureflagevaluation-hook-enqueue-typical

  • 🟥 execution_time [+2.821µs; +2.953µs] or [+12.972%; +13.577%]

Account for every consumed event, report aggregation failures on their batch, and allow subsequent healthy flushes to complete.

Environment: Datadog workspace

Co-Authored-By: Codex GPT-6 <noreply@localhost>
@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Pipelines  Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 2391a8e | Docs | View more details | Give us feedback!

Incorporate upstream tracer updates and validate OpenFeature flushing with the rebuilt native periodic threads.

Environment: Datadog workspace

Co-Authored-By: Codex GPT-6 <noreply@localhost>
@vjfridge
vjfridge marked this pull request as ready for review October 23, 2026 02:49
@vjfridge
vjfridge requested review from a team as code owners October 23, 2026 02:49
@vjfridge
vjfridge requested review from avara1986, kathiehuang and lym953 and removed request for a team October 23, 2026 02:49
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-23T02:54:12.819468Z 2391a8e Draft marked ready
🔒 Security Review ✅ Completed 2026-10-23T02:54:04.943844Z 2391a8e Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2391a8e342

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +110 to +112
failed |= progress.last_failure >= started
if progress.accepted > progress.settled:
pending.append((writer, progress.accepted, progress.failures))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Report failures that settle before flush begins

When a periodic writer sends and rejects an invocation's batch before the invocation-end call starts, accepted == settled and last_failure < started, so this path returns completed even though the snapshotted work was not accepted by an endpoint. This is especially reachable for invocations longer than the writers' 1- or 10-second intervals. Track unreported failures across flush calls rather than considering only failures timestamped after this call began.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Comment from Claude working with Aaron Silverman:

[P2] I confirmed this with a test. Count failures per writer instead of comparing failure times with the call's start time.

When the endpoint rejects a background send before flush() starts, accepted == settled and last_failure < started, so flush() returns completed. This reproduces for both writers. With a count of failures already reported, the next call no longer returns completed after that failure, and later calls do not count it again.

Suggested fix and test — the test fails at this head and passes with the fix
--- a/ddtrace/internal/openfeature/_telemetry.py
+++ b/ddtrace/internal/openfeature/_telemetry.py
@@ -25,7 +25,7 @@ class Progress:
         self.accepted = 0
         self.settled = 0
         self.failures = 0
-        self.last_failure = float("-inf")
+        self.reported_failures = 0
 
     def accept(self) -> None:
         with self.lock:
@@ -36,7 +36,6 @@ class Progress:
             self.settled = target
             if not success:
                 self.failures += 1
-                self.last_failure = time.monotonic()
 
 
 class Writer(Protocol):
@@ -107,7 +106,9 @@ def flush(timeout: float) -> str:
         if not progress.lock.acquire(timeout=max(0.0, deadline - time.monotonic())):
             return "timed_out"
         try:
-            failed |= progress.last_failure >= started
+            # Count a failure in the next call, even if it settled before that call started.
+            failed |= progress.failures != progress.reported_failures
+            progress.reported_failures = progress.failures
             if progress.accepted > progress.settled:
                 pending.append((writer, progress.accepted, progress.failures))
         finally:
@@ -149,6 +150,7 @@ def flush(timeout: float) -> str:
                 return "timed_out"
             try:
                 failed |= progress.failures != failures
+                progress.reported_failures = progress.failures
                 if progress.settled < target:
                     waiting.append((writer, target, failures))
             finally:
--- a/tests/openfeature/test_telemetry_flush.py
+++ b/tests/openfeature/test_telemetry_flush.py
@@ -294,3 +294,13 @@ def test_ambiguous_send_failure_is_not_retried(kind):
     assert _telemetry.flush(1) == "failed"
     assert _telemetry.flush(1) == "completed"
     assert connection.request.call_count == 1
+
+
+@pytest.mark.parametrize("kind", ["evaluation", "exposure"])
+def test_rejection_before_flush_is_reported_once(kind):
+    writer, connection = make_writer(kind, status=500)
+    enqueue(writer)
+    writer.periodic()  # The background send is rejected before the invocation-end flush starts.
+    assert _telemetry.flush(1) == "failed"
+    assert _telemetry.flush(1) == "completed"
+    assert connection.request.call_count == 1

test_failure_does_not_poison_next_invocation and test_ambiguous_send_failure_is_not_retried still pass, along with the rest of the writer suites and tests/internal/test_serverless.py (266 tests).

Comment on lines +133 to +136
coordinator.worker.start()
except Exception:
coordinator.worker = None
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Convert coordinator startup errors into a flush result

On the first pending flush, PeriodicThread.start() can raise an OSError when the runtime cannot allocate a thread, and the explicit re-raise lets that exception escape the invocation-end boundary instead of returning one of its documented results. In a resource-constrained Lambda this can turn best-effort telemetry cleanup into an application-visible failure; return failed (or timed_out when the budget is exhausted) after resetting the worker.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Comment from Claude working with Aaron Silverman:

[P2] I confirmed this with a test. Return "failed" when the coordinator thread cannot start, and clear the pending request.

flush() documents three return values, but an OSError from PeriodicThread.start() escapes it. The Lambda wrapper catches the exception and logs a traceback, so the invocation is safe but gets no result. The fix still logs the error at debug level, then returns "failed". Clearing requested drops the writer references that the failed start leaves behind, and the test checks that too.

Suggested fix and test — the test fails at this head and passes with the fix
--- a/ddtrace/internal/openfeature/_telemetry.py
+++ b/ddtrace/internal/openfeature/_telemetry.py
@@ -133,7 +133,9 @@ def flush(timeout: float) -> str:
                 coordinator.worker.start()
             except Exception:
                 coordinator.worker = None
-                raise
+                coordinator.requested.clear()
+                log.debug("Feature flag telemetry flush worker failed to start", exc_info=True)
+                return "failed"
         coordinator.signal.set()
     finally:
         coordinator.lock.release()
--- a/tests/openfeature/test_telemetry_flush.py
+++ b/tests/openfeature/test_telemetry_flush.py
@@ -294,3 +294,14 @@ def test_ambiguous_send_failure_is_not_retried(kind):
     assert _telemetry.flush(1) == "failed"
     assert _telemetry.flush(1) == "completed"
     assert connection.request.call_count == 1
+
+
+def test_worker_start_failure_returns_failed_and_recovers():
+    writer, connection = make_writer()
+    enqueue(writer)
+    with mock.patch.object(_telemetry.PeriodicThread, "start", side_effect=OSError("can't start new thread")):
+        assert _telemetry.flush(1) == "failed"
+    assert _telemetry._coordinator.worker is None
+    assert not _telemetry._coordinator.requested
+    assert _telemetry.flush(1) == "completed"
+    assert connection.request.call_count == 1

The writer suites and tests/internal/test_serverless.py pass with this change (265 tests).

@datadog-datadog-prod-us1 datadog-datadog-prod-us1 Bot 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.

Bits Code Review: FAIL

The first flush can exceed its advertised timeout because coordinator startup waits without a deadline on thread-start synchronization. Contention during concurrent startup or post-fork restarts can therefore overrun the invocation’s remaining execution budget.

Open Bits AI session

🤖 Bits Code Review · Commit 2391a8e

# inherited worker against the parent's requested work.
coordinator.worker.__autorestart__ = False
try:
coordinator.worker.start()

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.

P2 [P2] Bound coordinator startup by the flush deadline

On the first flush with pending work, concurrent periodic-thread startup or post-fork restarts can block this call beyond the supplied timeout. PeriodicThread.start() acquires threads._forking_lock without a timeout and then waits indefinitely for native startup acknowledgement. The flush deadline is checked only after start() returns, so invocation-end flushing can exceed its remaining Lambda execution budget. Startup must respect the remaining deadline or occur outside the bounded caller path.

React: 👍 useful · 👎 wrong · 😕 unclear · 👀 right, but not acting on it

What each reaction means
  • 👍 Useful: correct, and you're fixing it or will.
  • 👎 Wrong: the finding is incorrect, for example the bug can't happen or the code was misread.
  • 😕 Unclear: you can't tell what it means or why it matters.
  • 👀 Right, not acting: correct, but intended, out of scope for this PR, a known risk, or too minor.

🤖 Bits Code Review · Open Bits AI session

self.failures = 0
self.last_failure = float("-inf")

def accept(self) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Comment from Claude working with Aaron Silverman:

[P1] Take Progress.accept() off the ResetObject proxy and the extra lock. It runs for every evaluation the writer accepts, in every service, not only in Lambda.

accept() is the only new statement on the evaluation enqueue path. It resolves through a ResetObject proxy and then enters a second proxied lock. Both callers already hold an intake lock (_lifecycle_lock in FlagEvaluationWriter.enqueue, _lock in ExposureWriter.enqueue), which serializes the increments. flush() only reads the count, and an event accepted after its snapshot is outside that flush's target, so a stale read is harmless and the second lock adds nothing. The benchmark bot on this PR reports +10–20% on three hook-enqueue scenarios, and every accepted evaluation pays that cost.

Evidence — the regression reproduces locally, and the suggested fix measures within 1% of main

I measured in one process, interleaved: 15 rounds of 50,000 FlagEvalEVPHook.finally_after calls with the hook_enqueue benchmark setup, on Python 3.12. The table shows the median ns per call. "main" is this head with the accept() call removed.

Variant protected full evaluation data
main 3658 6231
this PR 4419 (+20.8%) 6825 (+9.5%)
suggested fix 3692 (+0.9%) 6273 (+0.7%)

In a separate run, removing only the lock recovered about 40% of the delta.

Suggested fix — a plain Progress, reset after fork by a forksafe hook
--- a/ddtrace/internal/openfeature/_flagevaluation_writer.py
+++ b/ddtrace/internal/openfeature/_flagevaluation_writer.py
@@ -812,7 +812,7 @@ class FlagEvaluationWriter(PeriodicService):
         self._counter_lock = forksafe.Lock()
         self._drain_lock = forksafe.RLock()
         self._flush_lock = forksafe.Lock()
-        self._progress = typing.cast(Progress, forksafe.ResetObject(Progress))
+        self._progress = Progress()
         register(self)
 
         self._drain_worker: typing.Optional[PeriodicThread] = None
--- a/ddtrace/internal/openfeature/_telemetry.py
+++ b/ddtrace/internal/openfeature/_telemetry.py
@@ -15,6 +15,7 @@ from ddtrace.internal.threads import PeriodicThread
 
 FLUSH_API_VERSION = 1
 log = get_logger(__name__)
+_progress: "WeakSet[Progress]" = WeakSet()
 
 
 class Progress:
@@ -22,14 +23,18 @@ class Progress:
 
     def __init__(self) -> None:
         self.lock = forksafe.Lock()
+        self.reset()
+        _progress.add(self)
+
+    def reset(self) -> None:
         self.accepted = 0
         self.settled = 0
         self.failures = 0
         self.last_failure = float("-inf")
 
     def accept(self) -> None:
-        with self.lock:
-            self.accepted += 1
+        # Hot path: no proxy, no lock. Owners call this under their intake lock.
+        self.accepted += 1
 
     def finish(self, target: int, success: bool) -> None:
         with self.lock:
@@ -39,6 +44,12 @@ class Progress:
                 self.last_failure = time.monotonic()
 
 
+@forksafe.register
+def _reset_progress() -> None:
+    for progress in list(_progress):
+        progress.reset()
+
+
 class Writer(Protocol):
     _progress: Progress
 
--- a/ddtrace/internal/openfeature/writer.py
+++ b/ddtrace/internal/openfeature/writer.py
@@ -128,7 +128,7 @@ class ExposureWriter(PeriodicService):
         super().__init__(interval=interval)
         self._lock = RLock()
         self._flush_lock = forksafe.Lock()
-        self._progress = cast(Progress, forksafe.ResetObject(Progress))
+        self._progress = Progress()
         register(self)
         self._process_state = cast(_ExposureProcessState, forksafe.ResetObject(_ExposureProcessState))
         self._timeout: float = timeout

With this change, tests/openfeature/test_telemetry_flush.py, test_exposure_writer.py, test_flagevaluation_writer.py and tests/internal/test_serverless.py pass (264 tests). When I disable _reset_progress, test_fork_child_does_not_flush_parent_work fails for both writers, so the existing fork test covers the hook.

allows the native thread fork protocol to stop it; transport timeouts continue
to govern outstanding network operations during a fork.

## Release coordination

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Comment from Claude working with Aaron Silverman:

[P3] Keep the compatibility rule here, and move the rest of "Release coordination" to the PR description.

Most of this section is release process: recording versions and layer ARNs, validation steps, and a release record. One sentence describes the branch, not the code: "No published combination is claimed by this development branch." Two sentences describe behavior and belong in this file: the Lambda side checks contract version 1, and older tracers are a no-op.

Suggested change — keep the two compatibility sentences, delete the rest
--- a/ddtrace/internal/openfeature/README.md
+++ b/ddtrace/internal/openfeature/README.md
@@ -35,18 +35,7 @@ coordinator does not restart inherited work in the child. Its bounded idle wait
 allows the native thread fork protocol to stop it; transport timeouts continue
 to govern outstanding network operations during a fork.
 
-## Release coordination
+## Compatibility
 
-The first published tracer version, Lambda library version, and compatible layer
-ARN/version must be recorded together after release artifacts are available.
-No published combination is claimed by this development branch. The Lambda side
-checks contract version 1 and safely skips older tracers. Release the tracer
-capability first, then the Lambda wrapper and a layer containing both changes.
-Python versions whose tracer release lacks contract version 1 remain a no-op.
-
-Before declaring support, validate a short successful invocation and a short
-raising invocation on the published layer, repeat them in a warm sandbox, and
-confirm ingestion of both evaluation and exposure events for a unique test
-subject. Cover local Agent EVP delivery separately from agentless direct fallback.
-Record the runtime, architecture, tracer version, Lambda library version, layer
-ARN, invocation IDs and ingestion evidence in the release record.
+The Lambda side checks contract version 1 and safely skips older tracers. Python
+versions whose tracer release lacks contract version 1 remain a no-op.

@@ -0,0 +1,6 @@
fixes:
- |
OpenFeature: Enables compatible Datadog Lambda libraries to flush pending

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Comment from Claude working with Aaron Silverman:

[P3] Match the release-note conventions, and add the fork fix to the note.

  • All 17 other OpenFeature notes with a scope in releasenotes/notes/ use lowercase openfeature:. This one uses OpenFeature:.
  • 16 of those 17 start with ---.
  • docs/releasenotes.rst asks a fix to state the user-visible symptom: "Fixes an issue where occurs when ."
  • This PR also stops a forked child from sending exposure events that its parent had buffered (_ExposureProcessState). Before, those events could be sent twice. The description mentions resetting coordination state after a fork, but neither it nor the note mentions the duplicate exposures.
Suggested note — replaces the whole file
---
fixes:
  - |
    openfeature: Fixes an issue where feature flag evaluation and exposure events could be delayed
    or lost when an AWS Lambda invocation ended shortly after evaluating flags. Requires a Datadog
    Lambda library version that flushes these events at invocation end.
  - |
    openfeature: Fixes an issue where exposure events could be sent twice after a process forked.

It parses as YAML with two fixes entries. I could not run reno lint in my environment.

with self._lifecycle_lock:
self._accepting_events = False
self._request_drain_worker_stop()
# A stopped drain worker owns its dequeued event and final callback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Comment from Claude working with Aaron Silverman:

[P3] Clarify this comment. The lock check does not make on_shutdown fully bounded.

The comment says: "A stopped drain worker owns its dequeued event and final callback. Do not turn the application's bounded join into an unbounded wait."

  • The non-blocking _drain_lock check is correct. A drain worker that holds the lock runs the final drain through on_shutdown=self.periodic. I traced the shutdown path and found no lost events.
  • The next line, self.periodic(), still waits on _flush_lock. A coordinator or periodic flush holds that lock across network I/O, so on_shutdown can still wait for a send in flight.
Suggested wording
--- a/ddtrace/internal/openfeature/_flagevaluation_writer.py
+++ b/ddtrace/internal/openfeature/_flagevaluation_writer.py
@@ -1174,8 +1174,8 @@ class FlagEvaluationWriter(PeriodicService):
         with self._lifecycle_lock:
             self._accepting_events = False
         self._request_drain_worker_stop()
-        # A stopped drain worker owns its dequeued event and final callback.
-        # Do not turn the application's bounded join into an unbounded wait.
+        # A drain worker that holds the lock owns the final drain through its
+        # on_shutdown callback. periodic() can still wait for an in-flight send.
         if not self._drain_lock.acquire(blocking=False):
             return
         self._drain_lock.release()

self.signal.clear()
for writer in writers:
try:
writer.periodic()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Comment from Claude working with Aaron Silverman:

[P2] Wake running writers with PeriodicThread.awake() so they flush in parallel on their own threads. Keep the coordinator only for writers whose worker is not running.

When the coordinator drives the flush for both writers, it runs their periodic() calls one after the other, so the flush waits for both sends in sequence. With a local endpoint that takes 300 ms per request, flush(0.5) returned timed_out on every call. The parallel version returned completed in about 305 ms.

Other Datadog tracers use the parallel pattern. In dd-trace-dotnet, FlagEvaluationWriter.FlushAsync wakes the writer's own consumer and waits with a cap. In dd-trace-java, the feature-flagging shutdown joins every writer thread against one shared deadline. Since #20716, awake() does not block, so the caller still waits outside the writers' flush and intake locks.

Evidence — measured wall time of flush() with both writers pending

Both writers were started. A local HTTP server slept before each 202. Each row is the median of 8 flushes.

Delay per request Budget Current coordinator Parallel
50 ms 2 s 107 ms 56 ms
150 ms 2 s 308 ms 154 ms
300 ms 2 s 610 ms 306 ms
300 ms 500 ms 500 ms, timed_out 305 ms, completed

The provider starts FlagEvaluationWriter (_provider.py:284), and start_exposure_writer() starts the exposure writer (writer.py:349). Once both run, the coordinator thread starts only when a writer is not running. Until it starts, its 10 wakeups per second do not happen.

The check uses status, not only _worker. After stop(), awake() does nothing and _worker can remain set, so a stopped writer must still go to the coordinator.

Suggested fix and test — the test fails against the current coordinator
--- a/ddtrace/internal/openfeature/README.md
+++ b/ddtrace/internal/openfeature/README.md
@@ -15,12 +15,13 @@ drops after acceptance, and rejected or ambiguous deliveries are incomplete.
 Failures observed while coordinating the snapshot are also included. A failed
 invocation does not permanently poison subsequent flushes.
 
-One lazily created coordinator serves all invocation-end requests. Calls share a
-single monotonic deadline across registry/progress coordination and waiting for
-aggregation, route discovery, and delivery. The coordinator performs the writers'
-existing transport operations; the caller does not perform network I/O. Requests
-coalesce by writer, so repeated timeouts cannot accumulate helper workers or
-unbounded request queues. Providers, hooks, polling and ordinary writer workers
+Running writers flush on their own worker threads, in parallel, after a
+non-blocking wake. One lazily created coordinator serves writers whose worker is
+not running. Calls share a single monotonic deadline across registry/progress
+coordination and waiting for aggregation, route discovery, and delivery. The
+writer workers and the coordinator perform the existing transport operations;
+the caller does not perform network I/O. Requests coalesce by writer, so repeated
+timeouts cannot accumulate helper workers or unbounded request queues. Providers, hooks, polling and ordinary writer workers
 remain active across warm invocations.
 
 Timeout stops waiting, not delivery. Queued work and in-flight batches remain with
--- a/ddtrace/internal/openfeature/_telemetry.py
+++ b/ddtrace/internal/openfeature/_telemetry.py
@@ -10,6 +10,7 @@ from weakref import WeakSet
 
 from ddtrace.internal import forksafe
 from ddtrace.internal.logger import get_logger
+from ddtrace.internal.service import ServiceStatus
 from ddtrace.internal.threads import PeriodicThread
 
 
@@ -117,26 +118,36 @@ def flush(timeout: float) -> str:
     if not pending:
         return "failed" if failed else "completed"
 
-    coordinator = _coordinator
-    if not coordinator.lock.acquire(timeout=max(0.0, deadline - time.monotonic())):
-        return "timed_out"
-    try:
-        coordinator.requested.update(writer for writer, _, _ in pending)
-        if coordinator.worker is None:
-            coordinator.worker = PeriodicThread(
-                0.0, coordinator.run, name="ddtrace:feature-flag-flush", no_wait_at_start=True
-            )
-            # ResetObject replaces the coordinator in a child; don't restart its
-            # inherited worker against the parent's requested work.
-            coordinator.worker.__autorestart__ = False
-            try:
-                coordinator.worker.start()
-            except Exception:
-                coordinator.worker = None
-                raise
-        coordinator.signal.set()
-    finally:
-        coordinator.lock.release()
+    # Running writers flush on their own threads, in parallel. The coordinator
+    # serves writers whose worker is not running.
+    idle = []
+    for writer, _, _ in pending:
+        worker = getattr(writer, "_worker", None)
+        if worker is not None and getattr(writer, "status", None) is ServiceStatus.RUNNING:
+            worker.awake()
+        else:
+            idle.append(writer)
+    if idle:
+        coordinator = _coordinator
+        if not coordinator.lock.acquire(timeout=max(0.0, deadline - time.monotonic())):
+            return "timed_out"
+        try:
+            coordinator.requested.update(idle)
+            if coordinator.worker is None:
+                coordinator.worker = PeriodicThread(
+                    0.0, coordinator.run, name="ddtrace:feature-flag-flush", no_wait_at_start=True
+                )
+                # ResetObject replaces the coordinator in a child; don't restart its
+                # inherited worker against the parent's requested work.
+                coordinator.worker.__autorestart__ = False
+                try:
+                    coordinator.worker.start()
+                except Exception:
+                    coordinator.worker = None
+                    raise
+            coordinator.signal.set()
+        finally:
+            coordinator.lock.release()
 
     while pending:
         remaining = deadline - time.monotonic()
--- a/tests/openfeature/test_telemetry_flush.py
+++ b/tests/openfeature/test_telemetry_flush.py
@@ -294,3 +294,21 @@ def test_ambiguous_send_failure_is_not_retried(kind):
     assert _telemetry.flush(1) == "failed"
     assert _telemetry.flush(1) == "completed"
     assert connection.request.call_count == 1
+
+
+def test_running_writers_flush_in_parallel():
+    writers = [make_writer(kind) for kind in ("evaluation", "exposure")]
+    for writer, connection in writers:
+        connection.getresponse.side_effect = lambda: (time.sleep(0.2), mock.Mock(status=202))[1]
+        writer.start()
+        enqueue(writer)
+    try:
+        started = time.monotonic()
+        # One send at a time needs at least 0.4 s.
+        assert _telemetry.flush(0.35) == "completed"
+        assert time.monotonic() - started < 0.35
+        assert _telemetry._coordinator.worker is None
+    finally:
+        for writer, _ in writers:
+            writer.stop()
+            writer.join(2)

The writer suites and tests/internal/test_serverless.py pass (265 tests). The tests in test_telemetry_flush.py build writers without starting them, so they still cover the coordinator path. Against the current coordinator, the new test fails with timed_out.

@@ -0,0 +1,159 @@
"""Versioned internal boundary for Lambda's best-effort EVP flush.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Comment from Claude working with Aaron Silverman:

[P3] Consider renaming _telemetry now, before the module path becomes a cross-repo contract.

ddtrace.internal.openfeature._telemetry sits next to ddtrace.internal.telemetry, the instrumentation telemetry package, and _flagevaluation_writer.py imports both. After release, datadog-lambda-python imports this path and tests/internal/test_serverless.py pins it, so a later rename needs a compatibility alias or coordinated releases.

Suggested change: rename the module to _flush.py (or similar) and update its references: the imports in _flagevaluation_writer.py and writer.py; the allowlist entry and the new import test in tests/internal/test_serverless.py; tests/openfeature/test_telemetry_flush.py; this package's README.md; and the import in DataDog/datadog-lambda-python#872. The rename is mechanical, so I did not apply it.

assert _telemetry._coordinator.worker is None
"""
_, _, status, _ = run_python_code_in_subprocess(code)
assert status == 0

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.

This should already be covered in the test_serverless_imports above, yeah? The only thing different are these asserts, which are already tested in your other unit tests below.

@@ -1004,26 +1014,40 @@ def _stop_service(self, *args: typing.Any, **kwargs: typing.Any) -> None:
super()._stop_service(*args, **kwargs)

def periodic(self) -> None:

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.

What if..... we just call periodic directly from the lambda function? We already do that same thing for exception replay, so there's precident for it. That would mean this PR is completely unnecessary.....

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.

If that's true, then we could just do a single PR in datadog-lambda-python DataDog/datadog-lambda-python@5d3d3e3

That uses a bunch of code from ddtrace.internal which we don't love, but as long as it's well protected, I don't see why that smaller PR wouldn't work well.

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.

3 participants