Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
11 changes: 9 additions & 2 deletions src/questdb/ingress.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ __all__ = [

from datetime import datetime, timedelta
from enum import Enum
from typing import Any, Dict, List, Optional, Union
from typing import Any, Dict, List, Optional, Tuple, Union

import numpy as np
import pandas as pd
Expand Down Expand Up @@ -831,6 +831,7 @@ class Sender:
host: str,
port: Union[int, str],
*,
addresses: Optional[List[Tuple[str, Union[int, str]]]] = None,
bind_interface: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
Expand All @@ -852,7 +853,13 @@ class Sender:
protocol_version=None,
init_buf_size: int = 65536,
max_name_len: int = 127,
): ...
):
"""
:param addresses: Additional ``(host, port)`` pairs for failover
(HTTP/HTTPS only). On retriable errors the sender rotates through
all configured addresses in round-robin order.
"""
...
@staticmethod
def from_conf(
conf_str: str,
Expand Down
47 changes: 45 additions & 2 deletions src/questdb/ingress.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -1784,6 +1784,9 @@ cdef object parse_conf_str(
str conf_str):
"""
Parse a config string to a tuple of (Protocol, dict[str, str]).
The 'addr' key may appear multiple times for multi-url support;
all values are collected into a list under the '_addrs' key,
and the last 'addr' value is kept under the 'addr' key.
"""
cdef size_t c_len1
cdef const char* c_buf1
Expand All @@ -1794,6 +1797,7 @@ cdef object parse_conf_str(
cdef str key
cdef str value
cdef dict params = {}
cdef list addrs = []
cdef line_sender_utf8 c_conf_str_utf8
cdef questdb_conf_str_parse_err* err
cdef questdb_conf_str* c_conf_str
Expand All @@ -1812,6 +1816,8 @@ cdef object parse_conf_str(
while questdb_conf_str_iter_next(c_iter, &c_buf1, &c_len1, &c_buf2, &c_len2):
key = PyUnicode_FromStringAndSize(c_buf1, <Py_ssize_t>c_len1)
value = PyUnicode_FromStringAndSize(c_buf2, <Py_ssize_t>c_len2)
if key == 'addr':
addrs.append(value)
params[key] = value

questdb_conf_str_iter_free(c_iter)
Expand Down Expand Up @@ -1848,6 +1854,13 @@ cdef object parse_conf_str(
k: type_mappings.get(k, str)(v)
for k, v in params.items()
}

# Store the full list of addresses for multi-url support.
# This is set AFTER the type_mappings comprehension to avoid
# the list being converted to a string by the default str() coercion.
if addrs:
params['_addrs'] = addrs

return (Protocol.parse(service), params)


Expand Down Expand Up @@ -2089,6 +2102,7 @@ cdef class Sender:
str host,
object port,
*,
object addresses=None,
str bind_interface=None,
str username=None,
str password=None,
Expand All @@ -2115,6 +2129,9 @@ cdef class Sender:
cdef str port_str
cdef line_sender_protocol c_protocol
cdef line_sender_utf8 c_port
cdef line_sender_error* err = NULL
cdef line_sender_utf8 c_addr_host
cdef line_sender_utf8 c_addr_port
cdef qdb_pystr_buf* b = qdb_pystr_buf_new()
try:
protocol = Protocol.parse(protocol)
Expand All @@ -2130,6 +2147,27 @@ cdef class Sender:
str_to_utf8(b, <PyObject*>port_str, &c_port)
self._opts = line_sender_opts_new_service(c_protocol, c_host, c_port)

if addresses is not None:
for addr_entry in addresses:
if not isinstance(addr_entry, (tuple, list)) or len(addr_entry) != 2:
raise TypeError(
'"addresses" must be a list of (host, port) tuples')
addr_host_str = str(addr_entry[0])
addr_port_val = addr_entry[1]
if isinstance(addr_port_val, int):
addr_port_str = str(addr_port_val)
elif isinstance(addr_port_val, str):
addr_port_str = addr_port_val
else:
raise TypeError(
f'address port must be an int or a str, '
f'not {_fqn(type(addr_port_val))}')
str_to_utf8(b, <PyObject*>addr_host_str, &c_addr_host)
str_to_utf8(b, <PyObject*>addr_port_str, &c_addr_port)
if not line_sender_opts_address(
self._opts, c_addr_host, c_addr_port, &err):
raise c_err_to_py(err)

self._set_sender_fields(
b,
protocol,
Expand Down Expand Up @@ -2248,11 +2286,16 @@ cdef class Sender:

sender = Sender.__new__(Sender)

# Forward only the `addr=` parameter to the C API.
synthetic_conf_str = f'{protocol.tag}::addr={addr};'
# Forward addr parameter(s) to the C API.
# Multiple addr entries are supported for multi-url failover.
addrs = params.get('_addrs', [addr])
addr_parts = ';'.join(f'addr={a}' for a in addrs)
synthetic_conf_str = f'{protocol.tag}::{addr_parts};'
str_to_utf8(b, <PyObject*>synthetic_conf_str, &c_synthetic_conf_str)
sender._opts = line_sender_opts_from_conf(
c_synthetic_conf_str, &err)
if err != NULL:
raise c_err_to_py(err)

sender._set_sender_fields(
b,
Expand Down
7 changes: 7 additions & 0 deletions src/questdb/line_sender.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,13 @@ cdef extern from "questdb/ingress/line_sender.h":
line_sender_error** err_out
) noexcept nogil

bint line_sender_opts_address(
line_sender_opts* opts,
line_sender_utf8 host,
line_sender_utf8 port,
line_sender_error** err_out
) noexcept nogil

bint line_sender_opts_username(
line_sender_opts* opts,
line_sender_utf8 username,
Expand Down
10 changes: 9 additions & 1 deletion test/system_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
#!/usr/bin/env python3

import sys

from questdb.ingress import TimestampNanos

sys.dont_write_bytecode = True
import os
import shutil
Expand Down Expand Up @@ -29,7 +32,7 @@
import questdb.ingress as qi


QUESTDB_VERSION = '9.2.0'
QUESTDB_VERSION = '9.3.0'
QUESTDB_PLAIN_INSTALL_PATH = None
QUESTDB_AUTH_INSTALL_PATH = None
FIRST_ARRAY_RELEASE = (8, 4, 0)
Expand Down Expand Up @@ -325,5 +328,10 @@ def test_decimal_pyarrow(self):
scrubbed_data = [row[:-1] for row in resp['dataset']]
self.assertEqual(scrubbed_data, expected_data)

def test_sending_just_timestamps(self):
with qi.Sender('http', 'localhost', self.qdb_plain.http_server_port) as sender:
sender.row(table_name="just_timestamp_test", at=TimestampNanos.now())
sender.flush()
Comment on lines +331 to +334
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Mar 12, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This test is currently a no-op.

sender.row(..., at=TimestampNanos.now()) does not write anything unless at least one symbol or column is present, so this path just rewinds the buffer in src/questdb/ingress.pyx. With no assertion afterwards, the test stays green even if timestamp-only writes are unsupported or broken. Please either assert the no-op explicitly or add a real field and verify the row was persisted.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/system_test.py` around lines 331 - 334, The test_sending_just_timestamps
test is currently a no-op because sender.row(..., at=TimestampNanos.now())
doesn't write unless at least one column/symbol is present; update the test to
either assert the no-op explicitly (e.g., check that no rows were created) or
send a real field/symbol and then verify persistence. Modify the call to
sender.row in test_sending_just_timestamps to include a concrete column or
symbol (e.g., add a field name/value) and after sender.flush() query the table
to assert the row exists and has the expected timestamp, or alternatively add an
explicit assertion that no rows were written to confirm the no-op behavior;
refer to sender.row, TimestampNanos.now(), and the ingestion behavior in
src/questdb/ingress.pyx when making the change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is pre-existing code, not introduced by this PR — out of scope.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@nwoolmer, the diff actually shows this test was added in this PR — all four lines carry + markers, indicating they are new additions. The AI-generated summary also confirms it: "Added new test method test_sending_just_timestamps to send a row with only a timestamp."

Given that, the concern stands: as written the test is effectively a no-op (no column/symbol → nothing is written → no assertion). Would you like to either:

  1. Assert the no-op explicitly — e.g., verify no rows were persisted, or
  2. Add a real field and then query the table to confirm the row exists (matching the pattern of other tests in the file)?


if __name__ == '__main__':
unittest.main()
Loading
Loading