Skip to content

Commit c64a592

Browse files
committed
Add RecordWriterV3, perf tests
1 parent 0a50062 commit c64a592

6 files changed

Lines changed: 557 additions & 6 deletions

File tree

‎splunklib/searchcommands/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@
153153
from splunklib.searchcommands.eventing_command import EventingCommand
154154
from splunklib.searchcommands.external_search_command import ExternalSearchCommand, execute
155155
from splunklib.searchcommands.generating_command import GeneratingCommand
156+
from splunklib.searchcommands.internals import DiskBufferSettings
156157
from splunklib.searchcommands.reporting_command import ReportingCommand
157158
from splunklib.searchcommands.search_command import SearchMetric, dispatch
158159
from splunklib.searchcommands.streaming_command import StreamingCommand
@@ -173,6 +174,7 @@
173174
"Boolean",
174175
"Code",
175176
"Configuration",
177+
"DiskBufferSettings",
176178
"Duration",
177179
"EventingCommand",
178180
"ExternalSearchCommand",

‎splunklib/searchcommands/generating_command.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,13 +212,13 @@ def _execute_chunk_v2(self, process, chunk):
212212
for row in process:
213213
records.append(row)
214214
count += 1
215-
if count == self._record_writer._maxresultrows:
215+
if count == self._record_writer.maxresultrows:
216216
break
217217

218218
for row in records:
219219
self._record_writer.write_record(row)
220220

221-
if count == self._record_writer._maxresultrows:
221+
if count == self._record_writer.maxresultrows:
222222
self._finished = False
223223
else:
224224
self._finished = True

‎splunklib/searchcommands/internals.py‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@
1515
import csv
1616
import gzip
1717
import re
18+
import shutil
1819
import sys
20+
import tempfile
1921
import urllib.parse
2022
import warnings
2123
from collections import OrderedDict, deque, namedtuple
24+
from dataclasses import dataclass
2225
from io import StringIO, TextIOWrapper
2326
from itertools import chain
2427
from json import JSONDecoder, JSONEncoder
@@ -229,6 +232,23 @@ def replace(match):
229232
# endregion
230233

231234

235+
@dataclass(frozen=True, kw_only=True)
236+
class DiskBufferSettings:
237+
"""Controls disk-spill buffering for RecordWriterV3.
238+
239+
When set on a command via ``@Configuration(disk_buffer=DiskBufferSettings())``,
240+
the CSV reply buffer spills to a temp file instead of accumulating entirely in
241+
RAM. This trades some I/O overhead for a bounded memory footprint regardless
242+
of result set size.
243+
244+
Args:
245+
spool_size: Bytes kept in RAM before spilling to disk. Defaults to 4 MB.
246+
Set to 0 to always write directly to disk.
247+
"""
248+
249+
spool_size: int = 4 * 1024 * 1024
250+
251+
232252
class ConfigurationSettingsType(type):
233253
"""Metaclass for constructing ConfigurationSettings classes.
234254
@@ -306,6 +326,12 @@ def validate_configuration_setting(specification, name, value):
306326
constraint=lambda value: value in ("events", "reporting", "streaming"),
307327
supporting_protocols=[2],
308328
),
329+
# SDK-only: never sent to Splunk. supporting_protocols=[] keeps it out of iteritems().
330+
"disk_buffer": specification(
331+
type=DiskBufferSettings,
332+
constraint=None,
333+
supporting_protocols=[],
334+
),
309335
}
310336

311337

@@ -462,6 +488,10 @@ def __init__(self, ofile, maxresultrows=None):
462488
self._committed_record_count = 0
463489
self.custom_fields = set()
464490

491+
@property
492+
def maxresultrows(self):
493+
return self._maxresultrows
494+
465495
@property
466496
def is_flushed(self):
467497
return self._flushed
@@ -797,3 +827,78 @@ def _write_chunk(self, metadata, body):
797827
self.write(body)
798828
self._ofile.flush()
799829
self._flushed = True
830+
831+
832+
class RecordWriterV3(RecordWriterV2):
833+
"""RecordWriterV2 with disk-spill buffering via SpooledTemporaryFile.
834+
835+
Used when a command is configured with ``@Configuration(disk_buffer=DiskBufferSettings())``.
836+
The CSV reply buffer spills to a temp file instead of accumulating in a StringIO,
837+
so peak RAM is bounded by ``spool_size`` rather than the full result payload.
838+
"""
839+
840+
def __init__(self, ofile, maxresultrows=None, disk_buffer=None):
841+
if disk_buffer is None:
842+
raise ValueError("RecordWriterV3 requires a DiskBufferSettings instance")
843+
self._disk_buffer = disk_buffer
844+
super().__init__(ofile, maxresultrows)
845+
# Replace the StringIO created by RecordWriter.__init__ with a spool file
846+
raw = tempfile.SpooledTemporaryFile(
847+
max_size=self._disk_buffer.spool_size,
848+
mode="w+b",
849+
)
850+
self._buffer_raw = raw
851+
self._buffer = TextIOWrapper(raw, encoding="utf-8", newline="")
852+
self._writer = csv.writer(self._buffer, dialect=CsvDialect)
853+
self._writerow = self._writer.writerow
854+
855+
def write_chunk(self, finished=None):
856+
inspector = self._inspector
857+
self._committed_record_count += self.pending_record_count
858+
self._chunk_count += 1
859+
860+
if len(inspector) == 0:
861+
inspector = None
862+
863+
metadata = [("inspector", inspector), ("finished", finished)]
864+
865+
if metadata:
866+
metadata_bytes = str(
867+
"".join(
868+
self._iterencode_json(
869+
dict((n, v) for n, v in metadata if v is not None), 0
870+
)
871+
)
872+
).encode("utf-8")
873+
metadata_length = len(metadata_bytes)
874+
else:
875+
metadata_bytes = b""
876+
metadata_length = 0
877+
878+
# Flush TextIOWrapper so all pending CSV data lands in the binary spool file
879+
self._buffer.flush()
880+
881+
self._buffer_raw.seek(0, 2)
882+
body_length = self._buffer_raw.tell()
883+
self._buffer_raw.seek(0)
884+
885+
if metadata_length > 0 or body_length > 0:
886+
start_line = f"chunked 1.0,{metadata_length},{body_length}\n".encode("utf-8")
887+
self._ofile.write(start_line)
888+
self._ofile.write(metadata_bytes)
889+
shutil.copyfileobj(self._buffer_raw, self._ofile, length=65536)
890+
self._ofile.flush()
891+
self._flushed = True
892+
893+
self._clear()
894+
895+
def _clear(self):
896+
# Flush wrapper, reset the raw spool, re-sync wrapper position
897+
self._buffer.flush()
898+
self._buffer_raw.seek(0)
899+
self._buffer_raw.truncate()
900+
# Discard the wrapper's internal position cache by seeking it too
901+
self._buffer.seek(0)
902+
self._inspector.clear()
903+
self._pending_record_count = 0
904+
self._fieldnames = None

‎splunklib/searchcommands/search_command.py‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,11 @@
3434

3535
import splunklib.searchcommands.environment as environment
3636
from splunklib.client import Service
37-
from splunklib.searchcommands.decorators import Option
37+
from splunklib.searchcommands.decorators import ConfigurationSetting, Option
3838
from splunklib.searchcommands.internals import (
3939
CommandLineParser,
4040
CsvDialect,
41+
DiskBufferSettings,
4142
InputHeader,
4243
Message,
4344
MetadataDecoder,
@@ -46,6 +47,7 @@
4647
Recorder,
4748
RecordWriterV1,
4849
RecordWriterV2,
50+
RecordWriterV3,
4951
)
5052
from splunklib.searchcommands.validators import Boolean
5153
from splunklib.utils import ensure_str
@@ -746,9 +748,12 @@ def _process_protocol_v2(self, argv, ifile, ofile):
746748
# Write search command configuration for consumption by splunkd
747749
# noinspection PyBroadException
748750
try:
749-
self._record_writer = RecordWriterV2(
750-
ofile, getattr(self._metadata.searchinfo, "maxresultrows", None)
751-
)
751+
_disk_buffer = getattr(self._configuration, "disk_buffer", None)
752+
_maxresultrows = getattr(self._metadata.searchinfo, "maxresultrows", None)
753+
if _disk_buffer is not None:
754+
self._record_writer = RecordWriterV3(ofile, _maxresultrows, disk_buffer=_disk_buffer)
755+
else:
756+
self._record_writer = RecordWriterV2(ofile, _maxresultrows)
752757
self.fieldnames = []
753758
self.options.reset()
754759

@@ -1135,6 +1140,28 @@ def iteritems(self):
11351140

11361141
# endregion
11371142

1143+
# region SDK-only settings (not sent to Splunk)
1144+
1145+
disk_buffer = ConfigurationSetting(
1146+
doc="""
1147+
Enable disk-spill buffering for the CSV reply buffer.
1148+
1149+
Set to a :class:`DiskBufferSettings` instance to have the SDK write the
1150+
CEXC reply payload to a :mod:`tempfile.SpooledTemporaryFile` instead of
1151+
a ``StringIO``. The spool file stays in RAM up to ``spool_size`` bytes,
1152+
then spills to a temp directory.
1153+
1154+
This trades I/O overhead for bounded peak memory usage — useful for
1155+
commands that generate or pass through very large result sets.
1156+
1157+
Default: :const:`None` (StringIO, original behaviour)
1158+
1159+
Supported by: SDK only (not sent to Splunk)
1160+
"""
1161+
)
1162+
1163+
# endregion
1164+
11381165
# endregion
11391166

11401167

0 commit comments

Comments
 (0)