Skip to content

[agentserver] Make per-request span flush non-blocking - #48955

Open
Harsheet Shah (harsheet-shah) wants to merge 2 commits into
mainfrom
user/harsheetshah/agentserver-async-flush
Open

[agentserver] Make per-request span flush non-blocking#48955
Harsheet Shah (harsheet-shah) wants to merge 2 commits into
mainfrom
user/harsheetshah/agentserver-async-flush

Conversation

@harsheet-shah

Copy link
Copy Markdown
Contributor

Summary

The Responses endpoint flushes spans synchronously in the request finally block via flush_spans(), which runs TracerProvider.force_flush inline. On the async handler this blocks the asyncio event loop until the exporter drains, so under concurrency every request is serialised behind a single export (head-of-line blocking), and the export time is added to every response.

Changes

azure-ai-agentserver-core — two new public helpers in _tracing.py:

  • flush_spans_async() — offloads the blocking force_flush to a worker thread (run_in_executor) so it never stalls the event loop. Same durability guarantee as flush_spans().
  • schedule_flush_spans() — fire-and-forget flush that returns immediately so the response is not delayed. Retains a strong task reference; falls back to sync flush when no loop is running. Safe only when the platform grants a drain window before freezing.

azure-ai-agentserver-responses — the hot-path flush now dispatches on AGENTSERVER_FLUSH_MODE:

  • async (default) — await flush_spans_async(); off the event loop, identical durability, removes head-of-line blocking.
  • backgroundschedule_flush_spans(); respond first, flush in the background.
  • sync — legacy blocking flush_spans().

Measured impact

Deployed on a Foundry hosted MAF agent (uksouth, Responses protocol), 15 warm requests each, same image, only AGENTSERVER_FLUSH_MODE varied:

Mode median mean
sync (baseline) 8,283 ms 8,050 ms
background 6,987 ms 7,280 ms

0.8–1.3 s / request (~10–16%) off the response path even with light instrumentation; larger under heavy instrumentation (bigger span exports). Default async mode removes event-loop head-of-line blocking with no durability change.

Backwards compatibility

Default behaviour is async (still awaited, just off the event loop) — no span loss vs. today. sync restores the exact prior behaviour.

The Responses endpoint flushed spans synchronously in the request `finally`
block via `flush_spans()`, which runs `TracerProvider.force_flush` inline.
On the async handler this blocks the asyncio event loop until the exporter
drains, serialising concurrent requests behind a single export
(head-of-line blocking) and adding the export time to every response.

Add two helpers to azure-ai-agentserver-core:
- `flush_spans_async`: offloads the blocking force_flush to a worker thread
  so it never stalls the event loop (same durability guarantee).
- `schedule_flush_spans`: fire-and-forget flush that returns immediately so
  the response is not delayed (requires a platform drain window before freeze).

The Responses hot-path flush now dispatches on `AGENTSERVER_FLUSH_MODE`:
`async` (default, off the event loop), `background` (respond first), or
`sync` (legacy). Default `async` removes event-loop head-of-line blocking
with no change to durability.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 93f793d2-1b86-4677-908c-3722d4fef290
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
10 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI 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.

🟡 Changes recommended

The dependency floor and API snapshot are stale, background flushing is unbounded, and the new behavior lacks tests.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds non-blocking span flushing to prevent synchronous telemetry export from blocking async requests.

Changes:

  • Adds asynchronous and background flush helpers.
  • Introduces configurable per-request flush modes.
  • Documents the behavior changes.
File summaries
File Description
core/CHANGELOG.md Documents new tracing helpers.
core/_tracing.py Implements async and background flushing.
core/init.py Exports the new public helpers.
responses/CHANGELOG.md Documents configurable flush modes.
responses/_endpoint_handler.py Selects the flush strategy per request.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 5
  • Review effort level: Balanced

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_tracing.py Outdated
from azure.ai.agentserver.core import ( # pylint: disable=import-error,no-name-in-module
FoundryAgentRequestContext,
flush_spans,
flush_spans_async,
Comment on lines +57 to +58
"flush_spans_async",
"schedule_flush_spans",
return
try:
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, flush, timeout_millis)
# latency, but requires the platform to grant
# a brief drain window before freezing.
# "sync" -> flush_spans(): legacy blocking behaviour.
_flush_mode = os.environ.get("AGENTSERVER_FLUSH_MODE", "async").lower()
@github-actions

This comment has been minimized.

- Coalesce background flushing: at most one flush task runs at a time;
  concurrent requests collapse into a single follow-up pass instead of
  spawning a retained task per request (bounded under load).
- Raise azure-ai-agentserver-core floor to >=2.2.0b2 in responses, since
  the handler now imports flush_spans_async/schedule_flush_spans (added in b2).
- Record flush_spans_async/schedule_flush_spans in core api.md.
- Extract _flush_spans_for_mode dispatch helper (case/whitespace-insensitive,
  unknown values fall back to the async default).
- Add tests: flush_spans_async non-blocking/timeout/exception/no-op;
  schedule_flush_spans coalescing + sync fallback; handler flush-mode dispatch.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 93f793d2-1b86-4677-908c-3722d4fef290
Copilot AI review requested due to automatic review settings September 10, 2026 06:33
@harsheet-shah

Copy link
Copy Markdown
Contributor Author

Thanks for the review — addressed in 8fe884a:

1. Unbounded background flushing (_tracing.py): schedule_flush_spans now runs a single coalesced flush. At most one background flush task exists at a time; requests that arrive while a flush is in flight set a pending flag so the running task performs exactly one follow-up pass afterward (capturing spans produced during the active flush). No per-request task/executor-queue growth, and no redundant concurrent orce_flush calls. Access is confined to the event-loop thread, so no lock is needed. Covered by test_coalesces_concurrent_requests_into_one_followup (50 concurrent requests → 2 exports, not 51).

2. Stale core dependency floor (responses/pyproject.toml): raised azure-ai-agentserver-core to >=2.2.0b2 (the version that introduces the imported helpers), preventing a responses-only upgrade from failing at import time.

3. api.md missing exports: added flush_spans_async and schedule_flush_spans to azure-ai-agentserver-core/api.md. Note: I could not regenerate api.metadata.yml locally — my package feed only offers apiview-stub-generator 0.3.7 (CI uses 0.3.31) and it isn't installable here (missing pylint-guidelines-checker dep). Please let the APIView tooling refresh the apiMdSha256 hash, or point me at the right parser version.

4/5. Missing tests:

  • flush_spans_async: a blocking fake force_flush verifies another coroutine runs while the export is in flight (proves it's off the loop), plus timeout pass-through, exception swallowing, and no-op when the provider lacks force_flush.
  • Flush-mode dispatch: extracted a _flush_spans_for_mode helper and added parameterized tests asserting each of sync/background/async routes to the right helper, with case-insensitivity, whitespace trimming, and the empty/unknown → async fail-safe fallback.

All 17 new tests pass locally.

Copilot AI 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.

🟡 Changes recommended

Streaming spans are flushed before stream execution, and the API metadata hash is stale.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment on lines +999 to +1001
await _flush_spans_for_mode(
os.environ.get(_FLUSH_MODE_ENV, _DEFAULT_FLUSH_MODE)
)
def azure.ai.agentserver.core.set_request_context(context: FoundryAgentRequestContext) -> Token[FoundryAgentRequestContext]: ...


async def azure.ai.agentserver.core.flush_spans_async:async(timeout_millis: int = 5000) -> None: ...
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

[Pilot] PR Pipeline Failure Analysis

What failed

Azure Pipelines build 6809423 failed on python - pullrequest (Build Analyze + Build Test ubuntu2404_312). Three independent, unrelated failures:

1. CSpell (validation) — Build Analyze job
sdk/agentserver/azure-ai-agentserver-responses/tests/test_flush_dispatch.py:36:11 — Unknown word backgroundx.

2. Pylint (validation) — Build Analyze job
sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_tracing.py:629, function _coalesced_flushC4739 (docstring-missing-param): parameter timeout_millis is missing from the docstring. azure-ai-agentserver-core pylint check: FAIL(16).

3. E2E test failure — Build Test ubuntu2404_312
tests/e2e/resilience_contract/test_row_3_path_c.py:60, test tests.e2e.resilience_contract.test_row_3_path_c.test_row_3_path_c[stream=True].
AssertionError: assert 'cancelled' == 'failed' — after a simulated SIGKILL mid-foreground-handler and restart, the response ended up in cancelled state instead of the expected failed state.

Relevant pipeline output

sdk/agentserver/azure-ai-agentserver-responses/tests/test_flush_dispatch.py:36:11 - Unknown word (backgroundx)
Spelling errors detected. To correct false positives or learn about spell checking see: https://aka.ms/azsdk/engsys/spellcheck

azure/ai/agentserver/core/_tracing.py:629: [C4739(docstring-missing-param), _coalesced_flush] Params missing in docstring: "timeout_millis".
pylint check completed with exit code 16
Total checks: 2 | Failed: 1 | Worst exit code: 16

AssertionError: {'agent_reference': {...}, 'background': False, 'created_at': 1789023392, ...}
assert 'cancelled' == 'failed'
  - failed
  + cancelled
tests/e2e/resilience_contract/test_row_3_path_c.py:60: AssertionError

Recommended next steps

  • CSpell: fix the typo backgroundx -> background at test_flush_dispatch.py:36, or if intentional, add it to .vscode/cspell.json.
  • Pylint: add timeout_millis to the docstring Args: section of _coalesced_flush in azure/ai/agentserver/core/_tracing.py.
  • E2E test: investigate test_row_3_path_c[stream=True] — after kill+restart, the response is now landing in cancelled rather than failed. Since this PR changes the flush path (flush_spans_async / schedule_flush_spans), verify the restart/status-reconciliation logic isn't racing with the new async/background flush behavior around the AGENTSERVER_FLUSH_MODE=async default (e.g., a status update that used to complete before the synchronous flush no longer does). Re-run [stream=False] too to confirm whether it's stream-specific.
  • See https://aka.ms/ci-fix

Automated fix: Fix found, view and apply fix

Generated by Pipeline Analysis Next Steps · auto · 97.7 AIC · ⌖ 2.01 AIC · ⊞ 9.2K ·

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Hosted Agents sdk/agentserver/*

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants