|
15 | 15 | import csv |
16 | 16 | import gzip |
17 | 17 | import re |
| 18 | +import shutil |
18 | 19 | import sys |
| 20 | +import tempfile |
19 | 21 | import urllib.parse |
20 | 22 | import warnings |
21 | 23 | from collections import OrderedDict, deque, namedtuple |
| 24 | +from dataclasses import dataclass |
22 | 25 | from io import StringIO, TextIOWrapper |
23 | 26 | from itertools import chain |
24 | 27 | from json import JSONDecoder, JSONEncoder |
@@ -229,6 +232,23 @@ def replace(match): |
229 | 232 | # endregion |
230 | 233 |
|
231 | 234 |
|
| 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 | + |
232 | 252 | class ConfigurationSettingsType(type): |
233 | 253 | """Metaclass for constructing ConfigurationSettings classes. |
234 | 254 |
|
@@ -306,6 +326,12 @@ def validate_configuration_setting(specification, name, value): |
306 | 326 | constraint=lambda value: value in ("events", "reporting", "streaming"), |
307 | 327 | supporting_protocols=[2], |
308 | 328 | ), |
| 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 | + ), |
309 | 335 | } |
310 | 336 |
|
311 | 337 |
|
@@ -462,6 +488,10 @@ def __init__(self, ofile, maxresultrows=None): |
462 | 488 | self._committed_record_count = 0 |
463 | 489 | self.custom_fields = set() |
464 | 490 |
|
| 491 | + @property |
| 492 | + def maxresultrows(self): |
| 493 | + return self._maxresultrows |
| 494 | + |
465 | 495 | @property |
466 | 496 | def is_flushed(self): |
467 | 497 | return self._flushed |
@@ -797,3 +827,78 @@ def _write_chunk(self, metadata, body): |
797 | 827 | self.write(body) |
798 | 828 | self._ofile.flush() |
799 | 829 | 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 |
0 commit comments