Skip to content

Commit 497027c

Browse files
committed
Make Postgres remote query COPY stream binary-safe
1 parent 4f481b0 commit 497027c

3 files changed

Lines changed: 177 additions & 91 deletions

File tree

postgres/datadog_checks/postgres/remote_query.py

Lines changed: 61 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
RemoteQueryCopySql = Literal[
2828
'SELECT 1 AS value',
2929
'SELECT city, country FROM cities ORDER BY city',
30+
"SELECT decode('00ff80', 'hex') AS payload",
3031
"SELECT repeat('x', 1048576) AS payload",
3132
"SELECT repeat('x', 2097152) AS payload",
3233
"SELECT repeat('x', 4194304) AS payload",
@@ -36,9 +37,8 @@
3637
"SELECT i, repeat('x', 1000) AS payload FROM generate_series(1, 3000) AS i",
3738
]
3839

39-
CopyStreamFormat = Literal['csv']
40-
CopyStreamEvent = Mapping[str, Any]
41-
CopyStreamEmit = Callable[[CopyStreamEvent], None]
40+
CopyStreamFormat = Literal['csv', 'binary']
41+
CopyStreamEmit = Callable[[str, str, bytes], None]
4242

4343
if TYPE_CHECKING:
4444
from datadog_checks.postgres import PostgreSql
@@ -131,6 +131,13 @@ class _CopyStreamState:
131131
bytes_emitted: int = 0
132132

133133

134+
@dataclass(frozen=True)
135+
class CopyStreamEvent:
136+
event_type: str
137+
metadata: Mapping[str, Any]
138+
payload: bytes = b''
139+
140+
134141
class _CopyStreamFailure(Exception):
135142
def __init__(self, code: str, message: str, retryable: bool = False):
136143
self.code = code
@@ -164,29 +171,33 @@ def execute_agent_rpc_stream_copy(
164171
try:
165172
request = json.loads(request_json)
166173
except (TypeError, ValueError):
167-
emit(
174+
_emit_copy_event(
175+
emit,
168176
_stream_failed_event(
169177
'invalid_request', 'Invalid remote query request: request_json must be a valid JSON object.'
170-
)
178+
),
171179
)
172180
return
173181

174182
if not isinstance(request, Mapping):
175-
emit(
176-
_stream_failed_event('invalid_request', 'Invalid remote query request: request_json must be a JSON object.')
183+
_emit_copy_event(
184+
emit,
185+
_stream_failed_event(
186+
'invalid_request', 'Invalid remote query request: request_json must be a JSON object.'
187+
),
177188
)
178189
return
179190

180191
events = iter_agent_rpc_stream_copy_events(request, StaticPostgresCheckRegistry([check]))
181192
try:
182193
for event in events:
183-
emit(event)
194+
_emit_copy_event(emit, event)
184195
except BaseException:
185196
events.close()
186197
raise
187198

188199

189-
def iter_agent_rpc_stream_copy_events(request: Any, registry: PostgresCheckRegistry) -> Iterator[dict[str, Any]]:
200+
def iter_agent_rpc_stream_copy_events(request: Any, registry: PostgresCheckRegistry) -> Iterator[CopyStreamEvent]:
190201
"""Yield COPY byte-stream events for unit tests and callback adaptation."""
191202
started_at = time.monotonic()
192203
try:
@@ -298,7 +309,7 @@ def _execute_safe_query(
298309

299310
def _iter_copy_stream_events(
300311
check: 'PostgreSql', request: RemoteQueryCopyRequest, started_at: float
301-
) -> Iterator[dict[str, Any]]:
312+
) -> Iterator[CopyStreamEvent]:
302313
db_pool = getattr(check, 'db_pool', None)
303314
if db_pool is None:
304315
yield _stream_failed_event(
@@ -316,15 +327,17 @@ def _iter_copy_stream_events(
316327
)
317328
return
318329

319-
yield {
320-
'type': 'metadata',
321-
'status': 'STARTED',
322-
'format': request.format,
323-
'operation': request.operation,
324-
'chunkBytes': request.limits.chunk_bytes,
325-
'maxBytes': request.limits.max_bytes,
326-
'maxRowBytes': request.limits.max_row_bytes,
327-
}
330+
yield CopyStreamEvent(
331+
'metadata',
332+
{
333+
'status': 'STARTED',
334+
'format': request.format,
335+
'operation': request.operation,
336+
'chunkBytes': request.limits.chunk_bytes,
337+
'maxBytes': request.limits.max_bytes,
338+
'maxRowBytes': request.limits.max_row_bytes,
339+
},
340+
)
328341

329342
state = _CopyStreamState()
330343
error: _CopyStreamFailure | None = None
@@ -351,16 +364,15 @@ def _iter_copy_stream_events(
351364
)
352365
return
353366

354-
yield {
355-
'type': 'final',
356-
'status': 'SUCCEEDED',
357-
'stats': _copy_stream_stats(state, started_at, request.format),
358-
}
367+
yield CopyStreamEvent(
368+
'final',
369+
{'status': 'SUCCEEDED', 'stats': _copy_stream_stats(state, started_at, request.format)},
370+
)
359371

360372

361373
def _copy_stream_data_events(
362374
check: 'PostgreSql', request: RemoteQueryCopyRequest, state: _CopyStreamState, started_at: float
363-
) -> Iterator[tuple[dict[str, Any], _CopyStreamState]]:
375+
) -> Iterator[tuple[CopyStreamEvent, _CopyStreamState]]:
364376
limits = request.limits
365377
deadline = started_at + (limits.timeout_ms / 1000)
366378
copy_sql = _copy_stdout_sql(request.query, request.format)
@@ -413,10 +425,11 @@ def _copy_stream_data_events(
413425

414426

415427
def _copy_stdout_sql(query: str, stream_format: CopyStreamFormat) -> str:
416-
# CSV is the only initial COPY byte-stream format. It is compact, standard, and preserves raw COPY bytes.
417-
if stream_format != 'csv':
418-
raise _CopyStreamFailure('invalid_request', 'Unsupported COPY stream format.')
419-
return f'COPY ({query}) TO STDOUT WITH (FORMAT CSV)'
428+
if stream_format == 'csv':
429+
return f'COPY ({query}) TO STDOUT WITH (FORMAT CSV)'
430+
if stream_format == 'binary':
431+
return f'COPY ({query}) TO STDOUT WITH (FORMAT BINARY)'
432+
raise _CopyStreamFailure('invalid_request', 'Unsupported COPY stream format.')
420433

421434

422435
def _set_statement_timeout(cursor: Any, timeout_ms: int) -> str | None:
@@ -445,9 +458,17 @@ def _raise_if_timed_out(deadline: float) -> None:
445458
raise _CopyStreamFailure('timeout', 'COPY stream exceeded timeoutMs.', retryable=True)
446459

447460

448-
def _copy_data_event(data: bytearray, state: _CopyStreamState) -> tuple[dict[str, Any], _CopyStreamState]:
461+
def _copy_data_event(data: bytearray, state: _CopyStreamState) -> tuple[CopyStreamEvent, _CopyStreamState]:
449462
payload = bytes(data)
450-
event = {'type': 'data', 'sequence': state.sequence, 'data': payload, 'bytes': len(payload)}
463+
event = CopyStreamEvent(
464+
'data',
465+
{
466+
'sequence': state.sequence,
467+
'offset': state.bytes_emitted,
468+
'bytes': len(payload),
469+
},
470+
payload,
471+
)
451472
next_state = _CopyStreamState(
452473
sequence=state.sequence + 1,
453474
chunks_emitted=state.chunks_emitted + 1,
@@ -475,17 +496,20 @@ def _stream_failed_event(
475496
retryable: bool = False,
476497
stats: Mapping[str, Any] | None = None,
477498
elapsed_ms: int | None = None,
478-
) -> dict[str, Any]:
479-
event = {
480-
'type': 'final',
499+
) -> CopyStreamEvent:
500+
metadata = {
481501
'status': 'FAILED',
482502
'error': {'code': code, 'message': message, 'retryable': retryable},
483503
}
484504
if stats is not None:
485-
event['stats'] = dict(stats)
505+
metadata['stats'] = dict(stats)
486506
elif elapsed_ms is not None:
487-
event['stats'] = {'elapsedMs': elapsed_ms}
488-
return event
507+
metadata['stats'] = {'elapsedMs': elapsed_ms}
508+
return CopyStreamEvent('error', metadata)
509+
510+
511+
def _emit_copy_event(emit: CopyStreamEmit, event: CopyStreamEvent) -> None:
512+
emit(event.event_type, json.dumps(event.metadata, default=str), event.payload)
489513

490514

491515
def _response_columns(description: Sequence[Any], rows: Sequence[Sequence[Any]]) -> list[dict[str, str]]:

postgres/tests/test_remote_query.py

Lines changed: 73 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -433,16 +433,24 @@ def collect_copy_events(request, check):
433433
return list(iter_agent_rpc_stream_copy_events(request, StaticPostgresCheckRegistry([check])))
434434

435435

436+
def event_metadata(event):
437+
return event.metadata
438+
439+
440+
def event_payload(event):
441+
return event.payload
442+
443+
436444
def test_copy_stream_requires_explicit_operation_before_pool_access():
437445
pool = FakePool(copy_blocks=[b'1\n'])
438446
request = valid_copy_request()
439447
request.pop('operation')
440448

441449
events = collect_copy_events(request, make_check(pool=pool))
442450

443-
assert events[-1]['status'] == 'FAILED'
444-
assert events[-1]['error']['code'] == 'invalid_request'
445-
assert 'operation' in events[-1]['error']['message']
451+
assert event_metadata(events[-1])['status'] == 'FAILED'
452+
assert event_metadata(events[-1])['error']['code'] == 'invalid_request'
453+
assert 'operation' in event_metadata(events[-1])['error']['message']
446454
assert pool.requested_dbnames == []
447455

448456

@@ -452,9 +460,9 @@ def test_copy_stream_rejects_unknown_fields_without_echoing_secrets(caplog):
452460

453461
events = collect_copy_events(request, make_check(pool=pool))
454462

455-
assert events[-1]['status'] == 'FAILED'
456-
assert events[-1]['error']['code'] == 'invalid_request'
457-
assert 'password' in events[-1]['error']['message']
463+
assert event_metadata(events[-1])['status'] == 'FAILED'
464+
assert event_metadata(events[-1])['error']['code'] == 'invalid_request'
465+
assert 'password' in event_metadata(events[-1])['error']['message']
458466
assert 'SECRET_DO_NOT_LOG' not in str(events)
459467
assert 'SECRET_DO_NOT_LOG' not in caplog.text
460468
assert pool.requested_dbnames == []
@@ -466,9 +474,9 @@ def test_copy_stream_rejects_non_copy_allowlisted_queries_before_pool_access():
466474

467475
events = collect_copy_events(request, make_check(pool=pool))
468476

469-
assert events[-1]['status'] == 'FAILED'
470-
assert events[-1]['error']['code'] == 'invalid_request'
471-
assert 'query' in events[-1]['error']['message']
477+
assert event_metadata(events[-1])['status'] == 'FAILED'
478+
assert event_metadata(events[-1])['error']['code'] == 'invalid_request'
479+
assert 'query' in event_metadata(events[-1])['error']['message']
472480
assert pool.requested_dbnames == []
473481

474482

@@ -478,33 +486,46 @@ def test_copy_stream_uses_connection_pool_and_emits_chunked_copy_bytes():
478486

479487
events = collect_copy_events(valid_copy_request(), check)
480488

481-
assert events[0]['type'] == 'metadata'
482-
assert events[0]['operation'] == 'copy_stream'
483-
assert events[0]['format'] == 'csv'
484-
data_events = [event for event in events if event['type'] == 'data']
485-
assert [event['sequence'] for event in data_events] == [0, 1, 2]
486-
assert [event['data'] for event in data_events] == [b'abcdefgh', b'ijklmnop', b'qr']
487-
assert [event['bytes'] for event in data_events] == [8, 8, 2]
488-
assert events[-1]['type'] == 'final'
489-
assert events[-1]['status'] == 'SUCCEEDED'
490-
assert events[-1]['stats']['bytesEmitted'] == 18
491-
assert events[-1]['stats']['chunksEmitted'] == 3
489+
assert events[0].event_type == 'metadata'
490+
assert event_metadata(events[0])['operation'] == 'copy_stream'
491+
assert event_metadata(events[0])['format'] == 'csv'
492+
data_events = [event for event in events if event.event_type == 'data']
493+
assert [event_metadata(event)['sequence'] for event in data_events] == [0, 1, 2]
494+
assert [event_metadata(event)['offset'] for event in data_events] == [0, 8, 16]
495+
assert [event_payload(event) for event in data_events] == [b'abcdefgh', b'ijklmnop', b'qr']
496+
assert [event_metadata(event)['bytes'] for event in data_events] == [8, 8, 2]
497+
assert events[-1].event_type == 'final'
498+
assert event_metadata(events[-1])['status'] == 'SUCCEEDED'
499+
assert event_metadata(events[-1])['stats']['bytesEmitted'] == 18
500+
assert event_metadata(events[-1])['stats']['chunksEmitted'] == 3
492501
assert pool.requested_dbnames == ['datadog_test']
493502
assert pool.closed_copies == 1
494503

495504

505+
def test_copy_stream_data_event_payload_preserves_arbitrary_bytes():
506+
arbitrary_bytes = b'\x00\xff\x80abc\n'
507+
pool = FakePool(copy_blocks=[arbitrary_bytes])
508+
509+
events = collect_copy_events(valid_copy_request(), make_check(pool=pool))
510+
511+
data_events = [event for event in events if event.event_type == 'data']
512+
assert len(data_events) == 1
513+
assert event_payload(data_events[0]) == arbitrary_bytes
514+
assert isinstance(event_payload(data_events[0]), bytes)
515+
516+
496517
def test_copy_stream_enforces_max_bytes_without_exceeding_limit():
497518
pool = FakePool(copy_blocks=[b'abcdefgh', b'ijklmnop'])
498519
request = valid_copy_request(limits={'chunkBytes': 8, 'maxBytes': 10, 'maxRowBytes': 32, 'timeoutMs': 5000})
499520

500521
events = collect_copy_events(request, make_check(pool=pool))
501522

502-
data_events = [event for event in events if event['type'] == 'data']
503-
assert [event['data'] for event in data_events] == [b'abcdefgh', b'ij']
504-
assert sum(event['bytes'] for event in data_events) == 10
505-
assert events[-1]['status'] == 'FAILED'
506-
assert events[-1]['error']['code'] == 'max_bytes_exceeded'
507-
assert events[-1]['stats']['bytesEmitted'] == 10
523+
data_events = [event for event in events if event.event_type == 'data']
524+
assert [event_payload(event) for event in data_events] == [b'abcdefgh', b'ij']
525+
assert sum(event_metadata(event)['bytes'] for event in data_events) == 10
526+
assert event_metadata(events[-1])['status'] == 'FAILED'
527+
assert event_metadata(events[-1])['error']['code'] == 'max_bytes_exceeded'
528+
assert event_metadata(events[-1])['stats']['bytesEmitted'] == 10
508529
assert pool.closed_copies == 1
509530

510531

@@ -513,32 +534,41 @@ def test_copy_stream_enforces_max_row_bytes_after_copy_block_arrives():
513534

514535
events = collect_copy_events(valid_copy_request(), make_check(pool=pool))
515536

516-
assert [event['data'] for event in events if event['type'] == 'data'] == []
517-
assert events[-1]['status'] == 'FAILED'
518-
assert events[-1]['error']['code'] == 'max_row_bytes_exceeded'
519-
assert 'row granularity' in events[-1]['error']['message']
537+
assert [event_payload(event) for event in events if event.event_type == 'data'] == []
538+
assert event_metadata(events[-1])['status'] == 'FAILED'
539+
assert event_metadata(events[-1])['error']['code'] == 'max_row_bytes_exceeded'
540+
assert 'row granularity' in event_metadata(events[-1])['error']['message']
520541
assert pool.closed_copies == 1
521542

522543

523-
def test_agent_rpc_stream_copy_adapts_iterator_to_callback():
524-
pool = FakePool(copy_blocks=[b'1\n'])
544+
def test_agent_rpc_stream_copy_adapts_iterator_to_binary_safe_callback():
545+
arbitrary_bytes = b'\x00\xff\x80abc\n'
546+
pool = FakePool(copy_blocks=[arbitrary_bytes])
525547
events = []
526548

527-
execute_agent_rpc_stream_copy(json.dumps(valid_copy_request()), make_check(pool=pool), events.append)
549+
execute_agent_rpc_stream_copy(
550+
json.dumps(valid_copy_request()), make_check(pool=pool), lambda *event: events.append(event)
551+
)
528552

529-
assert [event['type'] for event in events] == ['metadata', 'data', 'final']
530-
assert events[1]['data'] == b'1\n'
531-
assert events[-1]['status'] == 'SUCCEEDED'
553+
assert [event[0] for event in events] == ['metadata', 'data', 'final']
554+
assert json.loads(events[1][1])['bytes'] == len(arbitrary_bytes)
555+
assert events[1][2] == arbitrary_bytes
556+
assert isinstance(events[1][2], bytes)
557+
assert json.loads(events[-1][1])['status'] == 'SUCCEEDED'
532558

533559

534560
def test_agent_rpc_stream_copy_rejects_malformed_json_without_echoing_input(caplog):
535561
pool = FakePool(copy_blocks=[b'1\n'])
536562
events = []
537563

538-
execute_agent_rpc_stream_copy('{"password": "SECRET_DO_NOT_LOG"', make_check(pool=pool), events.append)
564+
execute_agent_rpc_stream_copy(
565+
'{"password": "SECRET_DO_NOT_LOG"', make_check(pool=pool), lambda *event: events.append(event)
566+
)
539567

540-
assert events[-1]['status'] == 'FAILED'
541-
assert events[-1]['error']['code'] == 'invalid_request'
568+
metadata = json.loads(events[-1][1])
569+
assert events[-1][0] == 'error'
570+
assert metadata['status'] == 'FAILED'
571+
assert metadata['error']['code'] == 'invalid_request'
542572
assert 'SECRET_DO_NOT_LOG' not in str(events)
543573
assert 'SECRET_DO_NOT_LOG' not in caplog.text
544574
assert pool.requested_dbnames == []
@@ -548,13 +578,13 @@ def test_agent_rpc_stream_copy_closes_copy_when_callback_raises():
548578
pool = FakePool(copy_blocks=[b'12345678', b'abcdef'])
549579
events = []
550580

551-
def emit(event):
552-
events.append(event)
553-
if event['type'] == 'data':
581+
def emit(event_type, metadata_json, payload):
582+
events.append((event_type, metadata_json, payload))
583+
if event_type == 'data':
554584
raise RuntimeError('stop streaming')
555585

556586
with pytest.raises(RuntimeError, match='stop streaming'):
557587
execute_agent_rpc_stream_copy(json.dumps(valid_copy_request()), make_check(pool=pool), emit)
558588

559-
assert [event['type'] for event in events] == ['metadata', 'data']
589+
assert [event[0] for event in events] == ['metadata', 'data']
560590
assert pool.closed_copies == 1

0 commit comments

Comments
 (0)