Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
15e62f8
PYTHON-6040 Update changelog and DriverInfo docstring per review
blink1073 Sep 16, 2026
7afe515
PYTHON-6040 Refactor driver metadata truncation to keep name/version …
blink1073 Sep 16, 2026
1d94bfb
Merge remote-tracking branch 'upstream/main' into PYTHON-6040
blink1073 Sep 16, 2026
e9e18cd
PYTHON-6040 Address review feedback on metadata truncation and tests
blink1073 Sep 17, 2026
1af3f6f
PYTHON-6040 Keep delimiter prose test, drop duplicate unit test
blink1073 Sep 17, 2026
dbc8511
PYTHON-6040 Track metadata size incrementally during truncation
blink1073 Sep 17, 2026
002956a
PYTHON-6040 Check driver pair survived truncation instead of counting…
blink1073 Sep 17, 2026
3dfc8c3
PYTHON-6040 Use strict zip when checking for surviving driver pair
blink1073 Sep 17, 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
15 changes: 15 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,23 @@ 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. The
comparison is exact, so drivers that differ in name case or platform are no
longer treated as duplicates (`PYTHON-6040`_).
- ``driver.name`` and ``driver.version`` in the handshake metadata are now
``|``-delimited lists with 1:1 index correspondence, including empty version
entries for the built-in ``|c`` and ``|async`` name segments
(`PYTHON-6040`_).
- Fixed a bug where truncating the handshake metadata to 512 bytes could leave
``driver.name`` and ``driver.version`` with different numbers of ``|``
delimiters (`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
12 changes: 10 additions & 2 deletions pymongo/driver_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,14 @@ class DriverInfo(namedtuple("DriverInfo", ["name", "version", "platform"])):
The MongoDB server logs PyMongo's name, version, and platform whenever
PyMongo establishes a connection. A driver implemented on top of PyMongo
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.
like 'MyDriver', '1.2.3', 'some platform info'. Any of these strings may
be None. A None or empty name or version appends an empty metadata
segment, keeping the ``driver.name`` and ``driver.version`` segment
counts aligned. A None or empty platform omits the platform.

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 +48,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
196 changes: 142 additions & 54 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,60 +202,117 @@ 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 "",
)


def _element_size(key: str, value: Any) -> int:
"""Size in bytes of the BSON element for ``key``, excluding doc overhead."""
# bson.encode({key: value}) is 4 bytes of document header plus 1 byte of
# document terminator on top of the element itself.
return len(bson.encode({key: value})) - 5


# 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."""
if len(bson.encode(metadata)) <= _MAX_METADATA_SIZE:
# The only full encode is the initial size check; each step then shrinks
# the tracked size by the exact bytes its change removes.
size = len(bson.encode(metadata))
if size <= _MAX_METADATA_SIZE:
return
# 1. Omit fields from env except env.name.
env_name = metadata.get("env", {}).get("name")
if env_name:
metadata["env"] = {"name": env_name}
if len(bson.encode(metadata)) <= _MAX_METADATA_SIZE:
env = {"name": env_name}
size += _element_size("env", env) - _element_size("env", metadata["env"])
metadata["env"] = env
if size <= _MAX_METADATA_SIZE:
return
# 2. Omit fields from os except os.type.
os_type = metadata.get("os", {}).get("type")
if os_type:
metadata["os"] = {"type": os_type}
if len(bson.encode(metadata)) <= _MAX_METADATA_SIZE:
old_os = metadata["os"]
new_os = {"type": os_type}
size += _element_size("os", new_os) - _element_size("os", old_os)
metadata["os"] = new_os
if size <= _MAX_METADATA_SIZE:
return
# 3. Omit the env document entirely.
metadata.pop("env", None)
encoded_size = len(bson.encode(metadata))
if encoded_size <= _MAX_METADATA_SIZE:
env = metadata.pop("env", None)
if env is not None:
size -= _element_size("env", env)
if size <= _MAX_METADATA_SIZE:
return
# 4. Truncate platform.
overflow = encoded_size - _MAX_METADATA_SIZE
overflow = size - _MAX_METADATA_SIZE
plat = metadata.get("platform", "")
if plat:
plat = plat[:-overflow]
if plat:
metadata["platform"] = plat
truncated = _truncate_utf8(plat, overflow)
if truncated:
size += _element_size("platform", truncated) - _element_size("platform", plat)
else:
size -= _element_size("platform", plat)
if truncated:
metadata["platform"] = truncated
else:
del metadata["platform"]
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
# The platform field may be present but empty.
plat = metadata.pop("platform", None)
if plat is not None:
size -= _element_size("platform", plat)
# 5. Truncate driver info, keeping name and version 1:1 index-aligned.

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 store pairs of (name, version) directly in a list and then do a single join on "|" when we actually need to serialize the metadata? Then we could remove a lot of the string splitting and make our pair requirement easier to follow and maintain.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done

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"]
# Keep the name and version segments paired so they stay 1:1 aligned,
# trimming wrapper content and dropping paired segments only as a
# last resort. Blank pairs (for example from a platform-only appended
# driver) are dropped first since they cost nothing.
name_str = driver.get("name", "")
version_str = driver.get("version", "")
raw_pairs = list(zip(name_str.split("|"), version_str.split("|")))
pairs = [(name, version) for name, version in raw_pairs if name or version]
new_name = "|".join(name for name, _ in pairs)
new_version = "|".join(version for _, version in pairs)
size += (len(new_name.encode("utf-8")) - len(name_str.encode("utf-8"))) + (
len(new_version.encode("utf-8")) - len(version_str.encode("utf-8"))
)
driver["name"] = new_name
driver["version"] = new_version
overflow = size - _MAX_METADATA_SIZE
while overflow > 0 and len(pairs) > 1:
# A single remaining pair never exceeds the limit: it is the base
# driver pair, which steps 1-4 left small enough to fit.
last_name, last_version = pairs[-1]

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.

Should we first pop any empty pairs? For example, if for some reason we had a platform-only DriverInfo get appended, it would also add a blank pair of name and version that we could truncate for free instead of potentially losing real data.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done

if last_version:
new_version = _truncate_utf8(last_version, overflow)
overflow -= len(last_version.encode("utf-8")) - len(new_version.encode("utf-8"))
pairs[-1] = (last_name, new_version)
elif last_name:
new_name = _truncate_utf8(last_name, overflow)
overflow -= len(last_name.encode("utf-8")) - len(new_name.encode("utf-8"))
pairs[-1] = (new_name, last_version)
else:
pairs.pop()
overflow -= len(f"|{last_name}|{last_version}".encode())
driver["name"] = "|".join(name for name, _ in pairs)
driver["version"] = "|".join(version for _, version in pairs)


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

__slots__ = (
"__appended_drivers",
"__appname",
"__compression_settings",
"__connect_timeout",
Expand All @@ -288,6 +348,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 +397,11 @@ def __init__(
self.__load_balanced = load_balanced
self.__credentials = credentials
self.__metadata = copy.deepcopy(_METADATA)
self.__appended_drivers: set[DriverInfo] = set()
# 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 +419,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 +442,42 @@ 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

# Only the top-level keys and the "driver" document are mutated,
# so shallow copies of those two are enough.
metadata = {**self.__metadata, "driver": dict(self.__metadata["driver"])}

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, so __appended_drivers stays bounded. Truncation
# keeps name and version index-aligned, so strict=True never
# raises.
names = metadata["driver"]["name"].split("|")
versions = metadata["driver"]["version"].split("|")
if sys.version_info >= (3, 10):
pairs = zip(names, versions, strict=True)
else:
pairs = zip(names, versions)
if (driver.name, driver.version) in pairs:
self.__appended_drivers.add(driver)

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