Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ac6ed46
PYTHON-6040 Preserve 1:1 index correspondence in client metadata
blink1073 Sep 15, 2026
5f2e74c
PYTHON-6040 Update tests for index-aligned client metadata
blink1073 Sep 15, 2026
b3d18d1
PYTHON-6040 Address PR review feedback
blink1073 Sep 15, 2026
b035cfb
PYTHON-6040 Address Copilot review feedback
blink1073 Sep 15, 2026
61ba3a2
PYTHON-6040 Use fork-aware lock for metadata updates
blink1073 Sep 15, 2026
7e863c9
PYTHON-6040 Fix truncation order and platform recreation
blink1073 Sep 15, 2026
d45d987
PYTHON-6040 Reverse prose test case and trim truncation comment
blink1073 Sep 15, 2026
0194f47
PYTHON-6040 Bound appended-driver tracking
blink1073 Sep 15, 2026
b652b78
PYTHON-6040 Bound dedup tracking for platform-only appends
blink1073 Sep 15, 2026
a016133
PYTHON-6040 Cover empty-name entries in index correspondence
blink1073 Sep 15, 2026
b50cb0f
PYTHON-6040 Align index-correspondence prose test to specifications
blink1073 Sep 15, 2026
62e83d4
PYTHON-6040 Rename prose tests with their specification titles
blink1073 Sep 15, 2026
7ff1773
PYTHON-6040 Extend metadata unit test coverage
blink1073 Sep 15, 2026
affb338
PYTHON-6040 Trim wrapper name before dropping metadata pair
blink1073 Sep 15, 2026
6ef9ab0
PYTHON-6040 Truncate metadata by UTF-8 bytes
blink1073 Sep 15, 2026
e3ead2a
PYTHON-6040 Dedup metadata across None and empty strings
blink1073 Sep 15, 2026
917bc0a
Update pymongo/pool_options.py
blink1073 Sep 16, 2026
66ce419
Update test/asynchronous/test_client_metadata.py
blink1073 Sep 16, 2026
e8c253c
PYTHON-6040 Address PR review feedback
blink1073 Sep 16, 2026
dbc559d
PYTHON-6040 Use a null context for the async metadata lock
blink1073 Sep 16, 2026
7ecbb94
PYTHON-6040 Close test clients with unittest cleanup
blink1073 Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,15 @@ Bug fixes
- Fixed a bug where the synchronous client could permanently deadlock under
gevent when a greenlet was killed while checking a connection back into
the pool (`PYTHON-6074`_).
- ``MongoClient.append_metadata()`` and ``AsyncMongoClient.append_metadata()``
now detect duplicates by comparing the whole
:class:`~pymongo.driver_info.DriverInfo` instead of only its name
(`PYTHON-6040`_).
- :class:`~pymongo.driver_info.DriverInfo` now raises :class:`ValueError` when
any field contains the reserved ``|`` delimiter (`PYTHON-6040`_).

.. _PYTHON-6074: https://jira.mongodb.org/browse/PYTHON-6074
.. _PYTHON-6040: https://jira.mongodb.org/browse/PYTHON-6040

Changes in Version 4.18.1 (2026/09/10)
--------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions pymongo/driver_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ class DriverInfo(namedtuple("DriverInfo", ["name", "version", "platform"])):
can add its own info to this log message. Initialize with three strings
like 'MyDriver', '1.2.3', 'some platform info'. Any of these strings may be
None to accept PyMongo's default.

The ``|`` character is the reserved delimiter used to join appended
metadata, so it must not appear in any of the fields. A
:class:`ValueError` is raised if it does.
"""

def __new__(
Expand All @@ -42,5 +46,7 @@ def __new__(
raise TypeError(
f"Wrong type for DriverInfo {key} option, value must be an instance of str, not {type(value)}"
)
if value and "|" in value:
raise ValueError(f"DriverInfo {key} must not contain the '|' delimiter")

return self
134 changes: 95 additions & 39 deletions pymongo/pool_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import platform
import sys
from collections.abc import MutableMapping
from contextlib import AbstractContextManager, nullcontext
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional

Expand All @@ -37,6 +38,7 @@
WAIT_QUEUE_TIMEOUT,
has_c,
)
from pymongo.lock import _create_lock

if TYPE_CHECKING:
from pymongo.auth_shared import MongoCredential
Expand Down Expand Up @@ -200,6 +202,25 @@ def _metadata_env() -> dict[str, Any]:
_MAX_METADATA_SIZE = 512


def _truncate_utf8(content: str, overflow: int) -> str:
"""Trim `overflow` UTF-8 bytes from the end of content, keeping a valid prefix."""
if overflow <= 0:
return content
data = content.encode("utf-8")
if len(data) <= overflow:
return ""
return data[: len(data) - overflow].decode("utf-8", errors="ignore")


def _normalize_driver(driver: DriverInfo) -> DriverInfo:
"""Treat None and "" as equivalent unset fields for deduplication."""
return driver._replace(
name=driver.name or "",
version=driver.version or "",
platform=driver.platform or "",
)


# See: https://github.com/mongodb/specifications/blob/master/source/mongodb-handshake/handshake.md#limitations
def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None:
"""Perform metadata truncation."""
Expand All @@ -226,34 +247,44 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None:
overflow = encoded_size - _MAX_METADATA_SIZE
plat = metadata.get("platform", "")
if plat:
plat = plat[:-overflow]
plat = _truncate_utf8(plat, overflow)
if plat:
metadata["platform"] = plat
else:
metadata.pop("platform", None)
encoded_size = len(bson.encode(metadata))
if encoded_size <= _MAX_METADATA_SIZE:
return
# 5. Truncate driver info.
overflow = encoded_size - _MAX_METADATA_SIZE
# 5. Truncate driver info, keeping name and version 1:1 index-aligned.
driver = metadata.get("driver", {})
if driver:
# Truncate driver version.
driver_version = driver.get("version")[:-overflow]
if len(driver_version) >= len(_METADATA["driver"]["version"]):
metadata["driver"]["version"] = driver_version
else:
metadata["driver"]["version"] = _METADATA["driver"]["version"]
encoded_size = len(bson.encode(metadata))
if encoded_size <= _MAX_METADATA_SIZE:
return
# Truncate driver name.
overflow = encoded_size - _MAX_METADATA_SIZE
driver_name = driver.get("name")[:-overflow]
if len(driver_name) >= len(_METADATA["driver"]["name"]):
metadata["driver"]["name"] = driver_name
else:
metadata["driver"]["name"] = _METADATA["driver"]["name"]
# Trim wrapper version and name content first, dropping paired segments
# only as a last resort, so name and version stay 1:1 aligned.
while True:
encoded_size = len(bson.encode(metadata))
if encoded_size <= _MAX_METADATA_SIZE:
break
overflow = encoded_size - _MAX_METADATA_SIZE
previous = (driver.get("name", ""), driver.get("version", ""))
n_parts = driver.get("name", "").split("|")
v_parts = driver.get("version", "").split("|")

if len(v_parts) > 1 and v_parts[-1]:
v_parts[-1] = _truncate_utf8(v_parts[-1], overflow)
driver["version"] = "|".join(v_parts)
elif len(n_parts) > 1 and n_parts[-1]:
n_parts[-1] = _truncate_utf8(n_parts[-1], overflow)
driver["name"] = "|".join(n_parts)
elif len(n_parts) > 1:
n_parts.pop()
v_parts.pop()
driver["name"] = "|".join(n_parts)
driver["version"] = "|".join(v_parts)
else:
break

if previous == (driver.get("name"), driver.get("version")):
break


# If the first getaddrinfo call of this interpreter's life is on a thread,
Expand All @@ -277,6 +308,7 @@ class PoolOptions:
"""

__slots__ = (
"__appended_drivers",
"__appname",
"__compression_settings",
"__connect_timeout",
Expand All @@ -288,6 +320,7 @@ class PoolOptions:
"__max_idle_time_seconds",
"__max_pool_size",
"__metadata",
"__metadata_lock",
"__min_pool_size",
"__pause_enabled",
"__server_api",
Expand Down Expand Up @@ -336,6 +369,11 @@ def __init__(
self.__load_balanced = load_balanced
self.__credentials = credentials
self.__metadata = copy.deepcopy(_METADATA)
self.__appended_drivers: list[DriverInfo] = []
# Only the synchronous client can append metadata from multiple threads.
self.__metadata_lock: AbstractContextManager[bool | None] = (
_create_lock() if is_sync else nullcontext()
)

if appname:
self.__metadata["application"] = {"name": appname}
Expand All @@ -353,11 +391,19 @@ def __init__(
self.__metadata["driver"]["name"],
"c",
)
self.__metadata["driver"]["version"] = "{}|{}".format(
self.__metadata["driver"]["version"],
"",
)
if not is_sync:
self.__metadata["driver"]["name"] = "{}|{}".format(
self.__metadata["driver"]["name"],
"async",
)
self.__metadata["driver"]["version"] = "{}|{}".format(
self.__metadata["driver"]["version"],
"",
)
if driver:
self._update_metadata(driver)

Expand All @@ -368,28 +414,38 @@ def __init__(
_truncate_metadata(self.__metadata)

def _update_metadata(self, driver: DriverInfo) -> None:
"""Updates the client's metadata"""
if driver.name and driver.name.lower() in self.__metadata["driver"]["name"].lower().split(
"|"
):
return

metadata = copy.deepcopy(self.__metadata)

if driver.name:
metadata["driver"]["name"] = "{}|{}".format(
metadata["driver"]["name"],
driver.name,
)
if driver.version:
"""Updates the client's metadata."""
with self.__metadata_lock:
driver = _normalize_driver(driver)
if driver in self.__appended_drivers:
return

name_delims = self.__metadata["driver"]["name"].count("|")
version_delims = self.__metadata["driver"]["version"].count("|")
metadata = copy.deepcopy(self.__metadata)

metadata["driver"]["name"] = "{}|{}".format(metadata["driver"]["name"], driver.name)
metadata["driver"]["version"] = "{}|{}".format(
metadata["driver"]["version"],
driver.version,
metadata["driver"]["version"], driver.version
)
if driver.platform:
metadata["platform"] = "{}|{}".format(metadata["platform"], driver.platform)

self.__metadata = metadata
if driver.platform:
if "platform" in metadata:
metadata["platform"] = "{}|{}".format(metadata["platform"], driver.platform)
else:
metadata["platform"] = driver.platform

_truncate_metadata(metadata)

self.__metadata = metadata

# Only track drivers whose appended name/version pair survived
# truncation (i.e. both gained a segment), so __appended_drivers
# stays bounded and the dedup membership check stays fast.
if (
metadata["driver"]["name"].count("|") > name_delims
and metadata["driver"]["version"].count("|") > version_delims
):
self.__appended_drivers.append(driver)

@property
def _credentials(self) -> Optional[MongoCredential]:
Expand Down
Loading
Loading