Skip to content

Commit 092a017

Browse files
[sqlserver] Group perf counter rows by counter name before dispatch
Every metric object scanned the whole sys.dm_os_performance_counters result set, and with autodiscovery both the metric count and the row count grow with the database count, so the dispatch cost grew with the square of the number of databases. Group the rows by counter name once per run instead. Also cache the counter type for counters that need no base counter, which was costing a round trip per counter per database on every metric list rebuild, and rebuild instance_per_type_metrics from scratch so counter names for metrics that are no longer collected stop being queried. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 05ce03c commit 092a017

5 files changed

Lines changed: 307 additions & 25 deletions

File tree

sqlserver/datadog_checks/sqlserver/metrics.py

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -96,29 +96,27 @@ class SqlSimpleMetric(BaseSqlServerMetric):
9696

9797
@classmethod
9898
def fetch_all_values(cls, cursor, counters_list, logger, databases=None, engine_edition=None):
99-
return cls._fetch_generic_values(cursor, counters_list, logger)
99+
rows, _ = cls._fetch_generic_values(cursor, counters_list, logger)
100+
# The name columns are nchar(128), so every value arrives blank-padded. Strip once here and group by
101+
# counter name so each metric only walks its own rows: with autodiscovery both the number of metrics
102+
# and the number of rows grow with the database count, making a per-metric scan quadratic.
103+
results = defaultdict(list)
104+
for counter_name, instance_name, object_name, cntr_value in rows:
105+
results[counter_name.strip()].append((instance_name.strip(), object_name.strip(), cntr_value))
106+
return results, None
100107

101-
def fetch_metric(self, rows, columns, values_cache=None):
102-
for counter_name_long, instance_name_long, object_name, cntr_value in rows:
103-
counter_name = counter_name_long.strip()
104-
instance_name = instance_name_long.strip()
105-
object_name = object_name.strip()
106-
if counter_name.strip() == self.sql_name:
107-
matched = False
108+
def fetch_metric(self, results, columns, values_cache=None):
109+
for instance_name, object_name, cntr_value in results.get(self.sql_name, ()):
110+
if (self.instance == ALL_INSTANCES and instance_name != "_Total") or (
111+
(instance_name == self.instance or instance_name == self.physical_db_name)
112+
and (not self.object_name or object_name == self.object_name)
113+
):
108114
metric_tags = list(self.tags)
109-
110-
if (self.instance == ALL_INSTANCES and instance_name != "_Total") or (
111-
(instance_name == self.instance or instance_name == self.physical_db_name)
112-
and (not self.object_name or object_name == self.object_name)
113-
):
114-
matched = True
115-
116-
if matched:
117-
if self.instance == ALL_INSTANCES:
118-
metric_tags.append('{}:{}'.format(self.tag_by, instance_name.strip()))
119-
self.report_function(self.metric_name, cntr_value, tags=metric_tags)
120-
if self.instance != ALL_INSTANCES:
121-
break
115+
if self.instance == ALL_INSTANCES:
116+
metric_tags.append('{}:{}'.format(self.tag_by, instance_name))
117+
self.report_function(self.metric_name, cntr_value, tags=metric_tags)
118+
if self.instance != ALL_INSTANCES:
119+
break
122120

123121

124122
class SqlFractionMetric(BaseSqlServerMetric):

sqlserver/datadog_checks/sqlserver/sqlserver.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -701,15 +701,19 @@ def _make_metric_list_to_collect(self, custom_metrics):
701701
self.instance_metrics = metrics_to_collect
702702
self.log.debug("metrics to collect %s", metrics_to_collect)
703703

704-
# create an organized grouping of metric names to their metric classes
704+
# create an organized grouping of metric names to their metric classes. Build it up locally and swap it in
705+
# at the end so a rebuild drops names that are no longer collected without ever leaving this mapping out of
706+
# sync with `instance_metrics`.
707+
per_type_metrics = defaultdict(set)
705708
for m in metrics_to_collect:
706709
cls = m.__class__.__name__
707710
name = m.sql_name or m.column
708711
self.log.debug("Adding metric class %s named %s", cls, name)
709712

710-
self.instance_per_type_metrics[cls].add(name)
713+
per_type_metrics[cls].add(name)
711714
if m.base_name:
712-
self.instance_per_type_metrics[cls].add(m.base_name)
715+
per_type_metrics[cls].add(m.base_name)
716+
self.instance_per_type_metrics = per_type_metrics
713717

714718
def _add_performance_counters(self, metrics, metrics_to_collect, tags, db=None, physical_database_name=None):
715719
if db is not None:
@@ -783,6 +787,10 @@ def get_sql_counter_type(self, counter_name):
783787
)
784788
except Exception as e:
785789
self.log.warning("Could not get counter_name of base for metric: %s", e)
790+
else:
791+
# Counters that need no base counter can be cached right away. Base-requiring counters are only
792+
# cached once their base is resolved, so a transient lookup failure is retried instead of pinned.
793+
self._sql_counter_types[counter_name] = (sql_counter_type, base_name)
786794

787795
return sql_counter_type, base_name
788796

sqlserver/hatch.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ COMPOSE_FOLDER = "compose"
4343
TZ="UTC"
4444
PIP_EXTRA_INDEX_URL = "https://datadoghq.dev/ci-wheels/bin"
4545

46+
# Benchmarks run against synthetic result sets and need no database, so this env only exists to make
47+
# `ddev test -b sqlserver` available. Standard `ddev test` runs skip benchmark environments.
48+
# The version variables are set because the shared test helpers read them at import time; the values
49+
# are arbitrary since no server is contacted.
50+
[envs.bench.env-vars]
51+
ODBCSYSINI = "{root}{/}tests{/}odbc"
52+
COMPOSE_FOLDER = "compose"
53+
TZ="UTC"
54+
PIP_EXTRA_INDEX_URL = "https://datadoghq.dev/ci-wheels/bin"
55+
SQLSERVER_YEAR = "2022"
56+
SQLSERVER_MAJOR_VERSION = "16"
57+
SQLSERVER_ENGINE_EDITION = "express"
58+
4659
[envs.default.overrides]
4760
env.GITHUB_ACTIONS.e2e-env = { value = false, if = ["true"], platform = ["windows"] }
4861
platform.windows.env-vars = [

sqlserver/tests/test_bench.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
import logging
5+
6+
import pytest
7+
8+
from datadog_checks.sqlserver.const import INSTANCE_METRICS, INSTANCE_METRICS_DATABASE
9+
from datadog_checks.sqlserver.metrics import SqlSimpleMetric
10+
11+
# Databases on the server that are always present, plus the aggregate instance the DMV reports.
12+
SERVER_DATABASES = ['master', 'tempdb', 'msdb', 'model', '_Total']
13+
14+
# The name columns of sys.dm_os_performance_counters are nchar(128), so values come back blank-padded.
15+
COUNTER_NAME_WIDTH = 128
16+
17+
# A real logger rather than a mock: mocks record every call, which would accumulate across benchmark
18+
# rounds and distort both the timing and the memory profile.
19+
benchmark_logger = logging.getLogger(__name__)
20+
benchmark_logger.addHandler(logging.NullHandler())
21+
benchmark_logger.setLevel(logging.WARNING)
22+
23+
24+
class StubCursor:
25+
"""Returns a fixed result set, standing in for the cursor `fetch_all_values` queries."""
26+
27+
description = [('counter_name',), ('instance_name',), ('object_name',), ('cntr_value',)]
28+
29+
def __init__(self, rows):
30+
self._rows = rows
31+
32+
def execute(self, *args, **kwargs):
33+
pass
34+
35+
def fetchall(self):
36+
return self._rows
37+
38+
39+
def _discard_metric(*args, **kwargs):
40+
pass
41+
42+
43+
def _row(counter_name, instance_name, object_name, cntr_value):
44+
return (
45+
counter_name.ljust(COUNTER_NAME_WIDTH),
46+
instance_name.ljust(COUNTER_NAME_WIDTH),
47+
object_name.ljust(COUNTER_NAME_WIDTH),
48+
cntr_value,
49+
)
50+
51+
52+
def _build_dispatch_inputs(num_databases):
53+
"""Build the metric objects and result set the check would hold for `num_databases` databases.
54+
55+
Mirrors `_add_performance_counters`: instance-level counters once, then the database-scoped
56+
counters once per autodiscovered database. Every counter is treated as a simple metric so the
57+
benchmark isolates the `SqlSimpleMetric` dispatch.
58+
"""
59+
databases = ['tenant_db_{:04d}'.format(i) for i in range(num_databases)]
60+
base_tags = ['database_hostname:sql-1', 'database_instance:sql-1', 'port:1433']
61+
62+
metrics = []
63+
for name, counter_name, instance_name, object_name in INSTANCE_METRICS:
64+
metrics.append(
65+
SqlSimpleMetric(
66+
{
67+
'name': name,
68+
'counter_name': counter_name,
69+
'instance_name': instance_name,
70+
'object_name': object_name,
71+
'tags': base_tags,
72+
},
73+
None,
74+
_discard_metric,
75+
None,
76+
benchmark_logger,
77+
)
78+
)
79+
for database in databases:
80+
database_tags = base_tags + ['database:{}'.format(database)]
81+
for name, counter_name, _, object_name in INSTANCE_METRICS_DATABASE:
82+
metrics.append(
83+
SqlSimpleMetric(
84+
{
85+
'name': name,
86+
'counter_name': counter_name,
87+
'instance_name': database,
88+
'object_name': object_name,
89+
'physical_db_name': database,
90+
'tags': database_tags,
91+
},
92+
None,
93+
_discard_metric,
94+
None,
95+
benchmark_logger,
96+
)
97+
)
98+
99+
# The query filters on counter name only, so database-scoped counters return a row for every
100+
# database on the server regardless of which ones autodiscovery selected.
101+
rows = [
102+
_row(counter_name, instance_name, object_name or 'SQLServer:Generic', 1)
103+
for _, counter_name, instance_name, object_name in INSTANCE_METRICS
104+
]
105+
for _, counter_name, _, object_name in INSTANCE_METRICS_DATABASE:
106+
rows.extend(
107+
_row(counter_name, database, object_name or 'SQLServer:Databases', 1)
108+
for database in databases + SERVER_DATABASES
109+
)
110+
111+
counter_names = sorted({counter_name for _, counter_name, _, _ in INSTANCE_METRICS + INSTANCE_METRICS_DATABASE})
112+
return metrics, rows, counter_names
113+
114+
115+
@pytest.mark.parametrize('num_databases', [50, 100, 250, 500])
116+
def test_simple_metric_dispatch(benchmark, num_databases):
117+
"""Time one check run's worth of perf-counter dispatch as the database count grows.
118+
119+
Both the number of metric objects and the number of rows scale with the database count, so this
120+
is where a per-metric scan of the full result set becomes quadratic. Compare the growth across
121+
the parameters rather than absolute times.
122+
"""
123+
metrics, rows, counter_names = _build_dispatch_inputs(num_databases)
124+
125+
def dispatch():
126+
results, columns = SqlSimpleMetric.fetch_all_values(StubCursor(rows), counter_names, benchmark_logger)
127+
for metric in metrics:
128+
metric.fetch_metric(results, columns)
129+
130+
benchmark(dispatch)

sqlserver/tests/test_unit.py

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
ENGINE_EDITION_AZURE_MANAGED_INSTANCE,
2020
ENGINE_EDITION_SQL_DATABASE,
2121
ENGINE_EDITION_STANDARD,
22+
PERF_COUNTER_BULK_COUNT,
2223
STATIC_INFO_ENGINE_EDITION,
2324
STATIC_INFO_FULL_SERVERNAME,
2425
STATIC_INFO_INSTANCENAME,
@@ -27,7 +28,7 @@
2728
STATIC_INFO_SERVERNAME,
2829
STATIC_INFO_VERSION,
2930
)
30-
from datadog_checks.sqlserver.metrics import SqlFractionMetric
31+
from datadog_checks.sqlserver.metrics import SqlFractionMetric, SqlSimpleMetric
3132
from datadog_checks.sqlserver.schemas import KEY_PREFIX, KEY_PREFIX_PRE_2017, SQLServerSchemaCollector
3233
from datadog_checks.sqlserver.sqlserver import SQLConnectionError
3334
from datadog_checks.sqlserver.utils import (
@@ -846,6 +847,138 @@ def test_SqlFractionMetric_group_by_instance(caplog):
846847
)
847848

848849

850+
# sys.dm_os_performance_counters declares the name columns as nchar(128), so every value comes back
851+
# blank-padded. Pad the fixtures the same way to exercise the stripping.
852+
def _padded_counter_row(counter_name, instance_name, object_name, cntr_value):
853+
return (counter_name.ljust(128), instance_name.ljust(128), object_name.ljust(128), cntr_value)
854+
855+
856+
SIMPLE_METRIC_ROWS = [
857+
_padded_counter_row('Processes blocked', '', 'SQLServer:General Statistics', 1),
858+
_padded_counter_row('Cache Pages', '_Total', 'SQLServer:Plan Cache', 10),
859+
_padded_counter_row('Cache Pages', 'SQL Plans', 'SQLServer:Plan Cache', 11),
860+
_padded_counter_row('Cache Pages', 'Object Plans', 'SQLServer:Plan Cache', 12),
861+
_padded_counter_row('Transaction Delay', 'tenant_a', 'SQLServer:Database Replica', 20),
862+
_padded_counter_row('Transaction Delay', 'tenant_a', 'SQLServer:Databases', 21),
863+
_padded_counter_row('Log Flushes/sec', '_Total', 'SQLServer:Databases', 30),
864+
_padded_counter_row('Log Flushes/sec', 'tenant_a', 'SQLServer:Databases', 31),
865+
_padded_counter_row('Log Flushes/sec', 'physical_a', 'SQLServer:Databases', 32),
866+
]
867+
868+
SIMPLE_METRIC_TAGS = ['optional:tag1', 'dd.internal.resource:database_instance:stubbed.hostname']
869+
870+
871+
@pytest.mark.parametrize(
872+
'cfg_overrides, expected',
873+
[
874+
pytest.param(
875+
{'counter_name': 'Processes blocked', 'instance_name': ''},
876+
[(1, SIMPLE_METRIC_TAGS)],
877+
id='instance level counter',
878+
),
879+
pytest.param(
880+
{'counter_name': 'Log Flushes/sec', 'instance_name': '_Total'},
881+
[(30, SIMPLE_METRIC_TAGS)],
882+
id='total instance of a per database counter',
883+
),
884+
pytest.param(
885+
{'counter_name': 'Log Flushes/sec', 'instance_name': 'tenant_a'},
886+
[(31, SIMPLE_METRIC_TAGS)],
887+
id='per database counter matched by instance_name',
888+
),
889+
pytest.param(
890+
{'counter_name': 'Log Flushes/sec', 'instance_name': 'tenant_b', 'physical_db_name': 'physical_a'},
891+
[(32, SIMPLE_METRIC_TAGS)],
892+
id='per database counter matched by physical_db_name',
893+
),
894+
pytest.param(
895+
{
896+
'counter_name': 'Transaction Delay',
897+
'instance_name': 'tenant_a',
898+
'object_name': 'SQLServer:Databases',
899+
},
900+
[(21, SIMPLE_METRIC_TAGS)],
901+
id='object_name selects among counters sharing a name',
902+
),
903+
pytest.param(
904+
{'counter_name': 'Transaction Delay', 'instance_name': 'tenant_a', 'object_name': 'SQLServer:Missing'},
905+
[],
906+
id='object_name matching nothing submits nothing',
907+
),
908+
pytest.param(
909+
{'counter_name': 'Cache Pages', 'instance_name': 'ALL', 'tag_by': 'plan_type'},
910+
[(11, SIMPLE_METRIC_TAGS + ['plan_type:SQL Plans']), (12, SIMPLE_METRIC_TAGS + ['plan_type:Object Plans'])],
911+
id='ALL instances tags each instance and skips _Total',
912+
),
913+
pytest.param(
914+
{
915+
'counter_name': 'Transaction Delay',
916+
'instance_name': 'ALL',
917+
'tag_by': 'db',
918+
'object_name': 'SQLServer:Missing',
919+
},
920+
[(20, SIMPLE_METRIC_TAGS + ['db:tenant_a']), (21, SIMPLE_METRIC_TAGS + ['db:tenant_a'])],
921+
id='ALL instances ignores object_name',
922+
),
923+
pytest.param(
924+
{'counter_name': 'Not Collected', 'instance_name': ''},
925+
[],
926+
id='counter absent from the result set submits nothing',
927+
),
928+
pytest.param(
929+
{'counter_name': 'Log Flushes/sec', 'instance_name': 'tenant_missing'},
930+
[],
931+
id='instance absent from the result set submits nothing',
932+
),
933+
],
934+
)
935+
def test_SqlSimpleMetric_fetch_metric(cfg_overrides, expected):
936+
mock_cursor = mock.MagicMock()
937+
mock_cursor.fetchall.return_value = SIMPLE_METRIC_ROWS
938+
mock_cursor.description = [('counter_name',), ('instance_name',), ('object_name',), ('cntr_value',)]
939+
940+
report_function = mock.MagicMock()
941+
cfg_instance = {
942+
'name': 'sqlserver.test.metric',
943+
'tags': list(SIMPLE_METRIC_TAGS),
944+
'hostname': 'stubbed.hostname',
945+
}
946+
cfg_instance.update(cfg_overrides)
947+
metric_obj = SqlSimpleMetric(
948+
cfg_instance=cfg_instance,
949+
base_name=None,
950+
report_function=report_function,
951+
column=None,
952+
logger=mock.MagicMock(),
953+
)
954+
955+
results, columns = SqlSimpleMetric.fetch_all_values(mock_cursor, [cfg_instance['counter_name']], mock.MagicMock())
956+
metric_obj.fetch_metric(results, columns)
957+
958+
assert report_function.call_args_list == [
959+
mock.call('sqlserver.test.metric', value, raw=True, hostname='stubbed.hostname', tags=tags)
960+
for value, tags in expected
961+
]
962+
963+
964+
def test_get_sql_counter_type_caches_counters_without_a_base(instance_docker):
965+
"""A counter type is immutable for the lifetime of the server, so it should only be queried once.
966+
967+
Without caching, rebuilding the metric list queries the type of every counter for every
968+
autodiscovered database, which is hundreds of round trips on an instance with many databases.
969+
"""
970+
check = SQLServer(CHECK_NAME, {}, [instance_docker])
971+
mock_cursor = mock.MagicMock()
972+
mock_cursor.fetchone.return_value = (PERF_COUNTER_BULK_COUNT,)
973+
check._connection = mock.MagicMock()
974+
check._connection.get_managed_cursor.return_value.__enter__.return_value = mock_cursor
975+
976+
for _ in range(3):
977+
assert check.get_sql_counter_type('Transactions/sec') == (PERF_COUNTER_BULK_COUNT, None)
978+
979+
assert mock_cursor.execute.call_count == 1
980+
981+
849982
def _mock_database_list():
850983
Row = namedtuple('Row', 'name')
851984
fetchall_results = [

0 commit comments

Comments
 (0)