From 57bf7c0273c6e91c2933c01ffe3b5895f323cfb0 Mon Sep 17 00:00:00 2001 From: Dhruv Gupta Date: Wed, 6 May 2026 22:20:28 +0000 Subject: [PATCH 1/3] Server-side rotation alias resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to app-templates#207. Moves the rotated-conversation_id bookkeeping from the chatbot's in-memory `Map` (lost on chatbot pod restart, blank on each new chatbot pod, missing in any new browser tab) into a `conversation_aliases` table the bridge owns. Clients always send the original chat id; the bridge resolves it forward at request entry to the post-rotation SDK session. What changed: - New `ConversationAlias` model and `agent_server.conversation_aliases` table (PK base_conversation_id → current_conversation_id). - `repository.resolve_conversation_alias(base)` (cheap forward lookup, miss returns the input) and `repository.upsert_conversation_alias(...)` (Postgres ON CONFLICT DO UPDATE so the table stays one row per logical conversation regardless of rotation count). - `_handle_background_request` resolves on the way in: `original_request` keeps the BASE id (anchor for future rotations stays flat — `chat-123 → chat-123::r-…-3`, never `chat-123::r-…-2::r-…-3`); the dispatched copy uses the resolved current id so the SDK lands on the rotated session. - `_try_claim_and_resume` upserts `base → rotated` after rotating, so alias persistence survives both bridge and chatbot restarts. - Rotation suffix changed from `{base}::attempt-N` to `{base}::r-{response_id_short}-N` so a second turn that crashes at the same attempt number can't collide with a prior turn's already-rotated session and re-poison it. The new test `test_rotate_no_collision_across_turns_at_same_attempt` covers this. - Existing rotation-format assertions updated; resume-path tests mock `upsert_conversation_alias` so they don't try to hit the DB. - New unit tests for `resolve` (hit + passthrough miss) and `upsert` (verifies the SQL is INSERT ... ON CONFLICT and binds correctly). The `response.resumed` SSE sentinel still fires for visibility / debug; the chatbot no longer reads it (companion PR drops the alias capture). 109 unit tests pass. Co-authored-by: Isaac Signed-off-by: Dhruv Gupta --- .../long_running/models.py | 27 +++++++ .../long_running/repository.py | 48 +++++++++++- .../long_running/server.py | 74 ++++++++++++++++--- .../test_long_running_db.py | 43 +++++++++++ .../test_long_running_server.py | 50 +++++++++---- 5 files changed, 217 insertions(+), 25 deletions(-) diff --git a/src/databricks_ai_bridge/long_running/models.py b/src/databricks_ai_bridge/long_running/models.py index dfdcca70..9f80a0dc 100644 --- a/src/databricks_ai_bridge/long_running/models.py +++ b/src/databricks_ai_bridge/long_running/models.py @@ -41,6 +41,33 @@ class Response(Base): messages = relationship("Message", back_populates="response", cascade="all, delete-orphan") +class ConversationAlias(Base): + """Maps a stable, client-visible ``conversation_id`` to its current + rotated form so the client never has to track rotation itself. + + On first use, ``base_conversation_id == current_conversation_id`` (no + rotation has happened). Each crash-resume rotates ``current`` to + ``{base}::attempt-N`` (anchored off ``base``, never off the prior + rotated form, so ids don't grow unboundedly across multiple crashes). + + The bridge resolves every incoming request's ``context.conversation_id`` + forward through this table before dispatching to the handler, so the + SDK's session/checkpointer always lands on the post-rotation thread. + """ + + __tablename__ = "conversation_aliases" + __table_args__ = {"schema": AGENT_DB_SCHEMA} + + base_conversation_id: Mapped[str] = mapped_column(Text, primary_key=True) + current_conversation_id: Mapped[str] = mapped_column(Text, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) + + class Message(Base): """Stream events and output items for a response. diff --git a/src/databricks_ai_bridge/long_running/repository.py b/src/databricks_ai_bridge/long_running/repository.py index 2c6a1ff0..2f28c161 100644 --- a/src/databricks_ai_bridge/long_running/repository.py +++ b/src/databricks_ai_bridge/long_running/repository.py @@ -8,7 +8,12 @@ from sqlalchemy.sql import bindparam, text from databricks_ai_bridge.long_running.db import session_scope -from databricks_ai_bridge.long_running.models import AGENT_DB_SCHEMA, Message, Response +from databricks_ai_bridge.long_running.models import ( + AGENT_DB_SCHEMA, + ConversationAlias, + Message, + Response, +) async def create_response( @@ -250,3 +255,44 @@ async def get_response(response_id: str) -> ResponseInfo | None: json.loads(row.original_request) if row.original_request else None, ) return None + + +async def resolve_conversation_alias(base_conversation_id: str) -> str: + """Return the current rotated form of a conversation id, or the input unchanged. + + Called on the request hot path, so it must be cheap. Misses (no row) are + expected on first contact for a new conversation; those return the input. + """ + async with session_scope() as session: + result = await session.execute( + select(ConversationAlias.current_conversation_id).where( + ConversationAlias.base_conversation_id == base_conversation_id + ) + ) + current = result.scalar_one_or_none() + return current if current is not None else base_conversation_id + + +async def upsert_conversation_alias( + base_conversation_id: str, current_conversation_id: str +) -> None: + """Record (or update) the mapping ``base -> current``. + + Postgres upsert keeps the table size at one row per logical conversation + regardless of how many times that conversation has been rotated. + """ + stmt = text( + f""" + INSERT INTO {AGENT_DB_SCHEMA}.conversation_aliases + (base_conversation_id, current_conversation_id, updated_at) + VALUES (:base, :current, now()) + ON CONFLICT (base_conversation_id) DO UPDATE + SET current_conversation_id = EXCLUDED.current_conversation_id, + updated_at = EXCLUDED.updated_at + """ + ) + async with session_scope() as session: + await session.execute( + stmt, {"base": base_conversation_id, "current": current_conversation_id} + ) + await session.commit() diff --git a/src/databricks_ai_bridge/long_running/server.py b/src/databricks_ai_bridge/long_running/server.py index 40007ef4..1884a989 100644 --- a/src/databricks_ai_bridge/long_running/server.py +++ b/src/databricks_ai_bridge/long_running/server.py @@ -44,8 +44,10 @@ get_messages, get_response, heartbeat_response, + resolve_conversation_alias, update_response_status, update_response_trace_id, + upsert_conversation_alias, ) from databricks_ai_bridge.long_running.settings import LongRunningSettings from databricks_ai_bridge.utils.annotations import experimental @@ -197,7 +199,7 @@ def _rotate_conversation_id( new_attempt_number: int, response_id: str, ) -> dict[str, Any]: - """Rotate the conversation anchor to a per-attempt value. + """Rotate the conversation anchor to a per-rotation-unique value. After a crash, attempt N+1 should see a FRESH checkpointer / session so it doesn't inherit mid-turn state that the SDK can't repair cleanly (most @@ -208,9 +210,12 @@ def _rotate_conversation_id( 2. context.conversation_id (fallback) 3. auto-generated (last resort) - We drop (1), pick the current base anchor, and write ``{base}::attempt-N`` - into (2). The handler then resolves to a fresh key for this attempt while - still being deterministic across retries of the same attempt. + We drop (1), pick the current base anchor, and write + ``{base}::r-{response_id_short}-{attempt}`` into (2). Including + ``response_id`` (truncated for readability) is what keeps multi-turn + rotations collision-free: turn 2's attempt-2 must not share a session + with turn 1's attempt-2 — both would otherwise mint + ``{base}::attempt-2`` and re-poison the just-rotated session. The LLM sees full turn history via ``original_request.input``, which was captured at the initial POST — before any attempt ran, so it's clean by @@ -231,9 +236,12 @@ def _rotate_conversation_id( custom_inputs.pop("session_id", None) request_dict["custom_inputs"] = custom_inputs + # response_id format is ``resp_<24hex>``; first 8 chars of the hex are + # plenty for collision-avoidance within one bridge deployment. + rid_short = response_id.removeprefix("resp_")[:8] or response_id ctx = request_dict.get("context") or {} ctx = dict(ctx) - rotated = f"{base_anchor}::attempt-{new_attempt_number}" + rotated = f"{base_anchor}::r-{rid_short}-{new_attempt_number}" ctx["conversation_id"] = rotated request_dict["context"] = ctx logger.info( @@ -517,6 +525,20 @@ async def _handle_background_request( # when tests pass a plain dict directly. dump = getattr(request_data, "model_dump", None) request_dict = dump() if callable(dump) else dict(request_data) + + # Forward-resolve the client-visible base conversation_id to its + # current rotated form so the SDK lands on the post-rotation + # session. The client never has to track rotation itself; it always + # sends the original base id. ``original_request`` keeps the BASE so + # later rotations anchor off it (and ids don't grow unboundedly + # across multiple crashes). The dispatched copy uses the resolved + # form so this turn's SDK session is the rotated one. + base_conv_id = (request_dict.get("context") or {}).get("conversation_id") + if base_conv_id: + current_conv_id = await resolve_conversation_alias(base_conv_id) + else: + current_conv_id = None + # Store the FULL request (untrimmed) as `original_request` so resume can # recover the entire prior-turn history. Per-template handlers are # responsible for deduping their own UI-echoed input against the SDK's @@ -527,7 +549,22 @@ async def _handle_background_request( durable=True, original_request=request_dict, ) - durable_request = self.validator.validate_and_convert_request(request_dict) + + if current_conv_id and current_conv_id != base_conv_id: + dispatch_dict = copy.deepcopy(request_dict) + ctx = dispatch_dict.get("context") or {} + ctx = dict(ctx) + ctx["conversation_id"] = current_conv_id + dispatch_dict["context"] = ctx + logger.info( + "[durable] resolved alias on POST response_id=%s base=%s current=%s", + response_id, + base_conv_id, + current_conv_id, + ) + else: + dispatch_dict = request_dict + durable_request = self.validator.validate_and_convert_request(dispatch_dict) logger.info( "Background response created response_id=%s stream=%s pod=%s", @@ -1113,13 +1150,28 @@ async def _try_claim_and_resume(self, response_id: str, resp) -> int | None: new_attempt - 1, response_id, ) + # Rotate ANCHORED off the base id stored in original_request — never + # off the prior rotated form — so multi-crash chains stay flat + # (chat-123 → chat-123::attempt-3, never chat-123::attempt-2::attempt-3). + base_conv_id = (resp.original_request.get("context") or {}).get("conversation_id") resume_dict = _rotate_conversation_id(resume_dict, new_attempt, response_id) - resume_request = self.validator.validate_and_convert_request(resume_dict) - # Surface the rotated conversation_id in the sentinel so clients that - # cache `chat_id → conversation_id` can pick up the rotation and use - # the rotated session on subsequent turns. Without this the next turn - # lands on the original (orphan-poisoned) session. rotated_conv_id = (resume_dict.get("context") or {}).get("conversation_id") + # Persist the alias so future requests for ``base_conv_id`` resolve + # forward to the rotated form on every pod, surviving chatbot restarts + # and multi-pod chatbot deployments. Without this, the client would + # need to remember the rotation itself. + if base_conv_id and rotated_conv_id and rotated_conv_id != base_conv_id: + await upsert_conversation_alias(base_conv_id, rotated_conv_id) + logger.info( + "[durable] persisted alias response_id=%s base=%s current=%s", + response_id, + base_conv_id, + rotated_conv_id, + ) + resume_request = self.validator.validate_and_convert_request(resume_dict) + # Keep emitting the response.resumed sentinel for visibility / debug + # / test assertions; clients no longer need to act on it for + # cross-turn alias tracking — the bridge handles that server-side. await append_message( response_id, next_seq, diff --git a/tests/databricks_ai_bridge/test_long_running_db.py b/tests/databricks_ai_bridge/test_long_running_db.py index 2565a1e0..1326c97d 100644 --- a/tests/databricks_ai_bridge/test_long_running_db.py +++ b/tests/databricks_ai_bridge/test_long_running_db.py @@ -21,8 +21,10 @@ create_response, get_messages, get_response, + resolve_conversation_alias, update_response_status, update_response_trace_id, + upsert_conversation_alias, ) @@ -206,6 +208,47 @@ async def test_get_response_not_found(mock_session): assert result is None +# --------------------------------------------------------------------------- +# Conversation alias tests (Option B: server-side rotation resolution) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_resolve_conversation_alias_returns_current_when_present(mock_session): + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = "chat-123::r-abcdef01-2" + mock_session.execute.return_value = result_mock + + out = await resolve_conversation_alias("chat-123") + assert out == "chat-123::r-abcdef01-2" + + +@pytest.mark.asyncio +async def test_resolve_conversation_alias_passthrough_when_absent(mock_session): + """A miss is the common case for fresh conversations — return the input + so the caller can dispatch with the client-sent id unchanged.""" + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = None + mock_session.execute.return_value = result_mock + + out = await resolve_conversation_alias("chat-fresh") + assert out == "chat-fresh" + + +@pytest.mark.asyncio +async def test_upsert_conversation_alias_executes_and_commits(mock_session): + await upsert_conversation_alias("chat-123", "chat-123::r-abcdef01-2") + mock_session.execute.assert_awaited_once() + mock_session.commit.assert_awaited_once() + # SQL is an INSERT ... ON CONFLICT DO UPDATE so a second crash on the same + # conversation overwrites rather than duplicating the row. + sql_text = str(mock_session.execute.await_args.args[0]) + assert "INSERT" in sql_text.upper() + assert "ON CONFLICT" in sql_text.upper() + bind_params = mock_session.execute.await_args.args[1] + assert bind_params == {"base": "chat-123", "current": "chat-123::r-abcdef01-2"} + + # --------------------------------------------------------------------------- # init_db / dispose_db / session_scope tests # --------------------------------------------------------------------------- diff --git a/tests/databricks_ai_bridge/test_long_running_server.py b/tests/databricks_ai_bridge/test_long_running_server.py index ae33b05c..156fec2e 100644 --- a/tests/databricks_ai_bridge/test_long_running_server.py +++ b/tests/databricks_ai_bridge/test_long_running_server.py @@ -1029,33 +1029,52 @@ def test_empty_attempt_emits_empty_events_array(self): class TestRotateConversationId: def test_rotate_drops_thread_id_and_sets_rotated_context(self): r = {"custom_inputs": {"thread_id": "t1", "user_id": "u"}, "context": {}} - out = _rotate_conversation_id(r, new_attempt_number=2, response_id="resp_x") + out = _rotate_conversation_id(r, new_attempt_number=2, response_id="resp_abcdef0123456789") assert "thread_id" not in out["custom_inputs"] assert out["custom_inputs"]["user_id"] == "u" - assert out["context"]["conversation_id"] == "t1::attempt-2" + assert out["context"]["conversation_id"] == "t1::r-abcdef01-2" def test_rotate_drops_session_id(self): r = {"custom_inputs": {"session_id": "s1"}, "context": {}} - out = _rotate_conversation_id(r, new_attempt_number=2, response_id="resp_x") + out = _rotate_conversation_id(r, new_attempt_number=2, response_id="resp_abcdef0123456789") assert "session_id" not in out["custom_inputs"] - assert out["context"]["conversation_id"] == "s1::attempt-2" + assert out["context"]["conversation_id"] == "s1::r-abcdef01-2" def test_rotate_falls_back_to_context_conversation_id(self): r = {"custom_inputs": {}, "context": {"conversation_id": "c-abc"}} - out = _rotate_conversation_id(r, new_attempt_number=3, response_id="resp_x") - assert out["context"]["conversation_id"] == "c-abc::attempt-3" + out = _rotate_conversation_id(r, new_attempt_number=3, response_id="resp_abcdef0123456789") + assert out["context"]["conversation_id"] == "c-abc::r-abcdef01-3" def test_rotate_falls_back_to_response_id_as_last_resort(self): r = {"custom_inputs": {}, "context": {}} - out = _rotate_conversation_id(r, new_attempt_number=2, response_id="resp_x") - assert out["context"]["conversation_id"] == "resp_x::attempt-2" + out = _rotate_conversation_id(r, new_attempt_number=2, response_id="resp_abcdef0123456789") + # Anchor falls back to response_id; suffix is a self-prefix. + assert out["context"]["conversation_id"] == "resp_abcdef0123456789::r-abcdef01-2" def test_rotate_handles_missing_custom_inputs_key(self): r = {"context": {"conversation_id": "c-abc"}} - out = _rotate_conversation_id(r, new_attempt_number=2, response_id="resp_x") - assert out["context"]["conversation_id"] == "c-abc::attempt-2" + out = _rotate_conversation_id(r, new_attempt_number=2, response_id="resp_abcdef0123456789") + assert out["context"]["conversation_id"] == "c-abc::r-abcdef01-2" assert out["custom_inputs"] == {} + def test_rotate_no_collision_across_turns_at_same_attempt(self): + """Two different responses (turns) on the same base conv_id, both at + attempt 2, must produce DIFFERENT rotated ids — otherwise turn 2's + rotation would land on turn 1's just-rotated session and re-poison it. + """ + base = {"custom_inputs": {}, "context": {"conversation_id": "chat-xyz"}} + a = _rotate_conversation_id( + dict(base, context=dict(base["context"])), + new_attempt_number=2, + response_id="resp_aaaaaaaa11111111", + ) + b = _rotate_conversation_id( + dict(base, context=dict(base["context"])), + new_attempt_number=2, + response_id="resp_bbbbbbbb22222222", + ) + assert a["context"]["conversation_id"] != b["context"]["conversation_id"] + class TestHandleBackgroundRequestPersistsDurabilityState: """Background request entry point should stamp the response row with the @@ -1204,6 +1223,7 @@ async def fake_append(response_id, seq, *, item=None, stream_event=None, attempt return_value=[_msg(0, None, {}), _msg(1, None, {})], ), patch(f"{MODULE}.append_message", side_effect=fake_append), + patch(f"{MODULE}.upsert_conversation_alias", new_callable=AsyncMock), patch("asyncio.create_task") as mock_create_task, ): attempt = await server._try_claim_and_resume("resp_x", resp) @@ -1256,6 +1276,7 @@ def add_done_callback(self, cb): patch(f"{MODULE}.claim_stale_response", new_callable=AsyncMock, return_value=2), patch(f"{MODULE}.get_messages", new_callable=AsyncMock, return_value=[]), patch(f"{MODULE}.append_message", new_callable=AsyncMock), + patch(f"{MODULE}.upsert_conversation_alias", new_callable=AsyncMock), patch("asyncio.create_task", side_effect=capture_task), patch.object(server, "_run_background_stream", new_callable=AsyncMock) as mock_run, ): @@ -1282,8 +1303,10 @@ def add_done_callback(self, cb): assert "thread_id" not in (dumped["custom_inputs"] or {}) # Other custom_inputs keys are preserved. assert dumped["custom_inputs"]["user_id"] == "u" - # conversation_id is rotated to a per-attempt value anchored on t1. - assert dumped["context"]["conversation_id"] == "t1::attempt-2" + # conversation_id is rotated to a per-rotation-unique value anchored + # on t1 with the response_id baked in (so multi-turn rotations don't + # collide at the same attempt number). + assert dumped["context"]["conversation_id"] == "t1::r-x-2" assert kwargs.get("attempt_number") == 2 @pytest.mark.asyncio @@ -1323,6 +1346,7 @@ def add_done_callback(self, cb): patch(f"{MODULE}.claim_stale_response", new_callable=AsyncMock, return_value=3), patch(f"{MODULE}.get_messages", new_callable=AsyncMock, return_value=[]), patch(f"{MODULE}.append_message", new_callable=AsyncMock), + patch(f"{MODULE}.upsert_conversation_alias", new_callable=AsyncMock), patch("asyncio.create_task", side_effect=capture_task), patch.object(server, "_run_background_stream", new_callable=AsyncMock) as mock_run, ): @@ -1340,7 +1364,7 @@ def add_done_callback(self, cb): # Rotation anchors on the stored context.conversation_id (priority 2). # Note: re-rotating in a subsequent attempt would re-anchor on the # ORIGINAL stored value, not the previous rotation — no stacking. - assert dumped["context"]["conversation_id"] == "resp_x::attempt-3" + assert dumped["context"]["conversation_id"] == "resp_x::r-x-3" class TestRetrieveTriggersLazyClaim: From f44e617ac4f3e14e9ca6a771d5f53121823a04b2 Mon Sep 17 00:00:00 2001 From: Dhruv Gupta Date: Wed, 6 May 2026 23:15:21 +0000 Subject: [PATCH 2/3] LongRunningAgentServer: attach bridge logger handler in __init__ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Templates currently each duplicate ~14 lines of `databricks_ai_bridge` logger setup in their start_server.py to surface durable-execution lifecycle logs (uvicorn's logging config drops INFO from non-uvicorn loggers, so without an explicit handler the breadcrumbs are silently swallowed and a deployed app looks dead even when working correctly). Move that to a single `_attach_bridge_logger()` called from LongRunningAgentServer.__init__. Idempotent — leaves existing handlers alone — so a consumer that ships their own logging config wins. Set DATABRICKS_AI_BRIDGE_LOG_QUIET=1 to opt out. Addresses Bryan's review on app-templates#207: "we can just gate this logging on an env var, but it feels like this belongs in ai-bridge instead." Companion app-templates change deletes the duplicated blocks. Co-authored-by: Isaac Signed-off-by: Dhruv Gupta --- .../long_running/server.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/databricks_ai_bridge/long_running/server.py b/src/databricks_ai_bridge/long_running/server.py index 1884a989..799774e9 100644 --- a/src/databricks_ai_bridge/long_running/server.py +++ b/src/databricks_ai_bridge/long_running/server.py @@ -254,6 +254,24 @@ def _rotate_conversation_id( return request_dict +def _attach_bridge_logger() -> None: + """Surface ``databricks_ai_bridge`` INFO logs in app stdout, since + uvicorn's logging config drops INFO from non-uvicorn loggers. Idempotent; + set ``DATABRICKS_AI_BRIDGE_LOG_QUIET=1`` to opt out. + """ + if os.environ.get("DATABRICKS_AI_BRIDGE_LOG_QUIET", "").lower() in ("1", "true", "yes"): + return + bridge_logger = logging.getLogger("databricks_ai_bridge") + if bridge_logger.level == logging.NOTSET or bridge_logger.level > logging.INFO: + bridge_logger.setLevel(logging.INFO) + if any(isinstance(h, logging.StreamHandler) for h in bridge_logger.handlers): + return + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")) + bridge_logger.addHandler(handler) + bridge_logger.propagate = False + + @experimental class LongRunningAgentServer(AgentServer): """AgentServer subclass adding background mode, retrieve endpoints, and @@ -336,6 +354,7 @@ def __init__( f"LongRunningAgentServer only supports '{self._SUPPORTED_AGENT_TYPE}', " f"got '{agent_type}'" ) + _attach_bridge_logger() self._settings = LongRunningSettings( task_timeout_seconds=task_timeout_seconds, poll_interval_seconds=poll_interval_seconds, From d93114091a7c9f8485cac73050d99ee3fc754b9a Mon Sep 17 00:00:00 2001 From: Dhruv Gupta Date: Wed, 6 May 2026 23:38:37 +0000 Subject: [PATCH 3/3] _attach_bridge_logger: don't disable propagation Setting propagate=False on the parent `databricks_ai_bridge` logger silently broke any test using caplog on a child logger (test_model_serving_obo_credential_strategy::test_logging_statements fails as soon as ANY test in the suite instantiates LongRunningAgentServer, since the logger is a process-level singleton). Propagation can stay on: in uvicorn deployments the root logger has no handlers, so propagation produces no double-log; in pytest, propagation is what lets caplog capture in the first place. Signed-off-by: Dhruv Gupta --- src/databricks_ai_bridge/long_running/server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/databricks_ai_bridge/long_running/server.py b/src/databricks_ai_bridge/long_running/server.py index 799774e9..aea688f3 100644 --- a/src/databricks_ai_bridge/long_running/server.py +++ b/src/databricks_ai_bridge/long_running/server.py @@ -269,7 +269,6 @@ def _attach_bridge_logger() -> None: handler = logging.StreamHandler(sys.stdout) handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")) bridge_logger.addHandler(handler) - bridge_logger.propagate = False @experimental