Skip to content

Commit ba18132

Browse files
authored
[4.4] Fix handling of sub-ms transaction timeouts (#1034)
Transaction timeouts are specified in seconds as float. However, the server expects it in milliseconds as int. This would lead to 1) rounding issues: previously, the driver would multiply by 1000 and then truncate to int. E.g., 256.4 seconds would be turned into 256399 ms because of float imprecision. Therefore, the built-in `round` is now used instead. 2) values below 1 ms (e.g., 0.0001) would be rounded down to 0. However, 0 is a special value that instructs the server to not apply any timeout. This is likely to surprise the user which specified a non-zero timeout. In this special case, the driver now rounds up to 1 ms. Backport of: #940 Backport adjustment: For better backwards compatibility, we're sending negative timeouts to the DBMS as the 4.4 driver did before (even though those values might not have the intuitive effect, we don't want to change the behavior of the driver too much in a patch release).
1 parent c4e3957 commit ba18132

File tree

10 files changed

+401
-45
lines changed

10 files changed

+401
-45
lines changed

neo4j/io/_bolt3.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
CommitResponse,
4848
InitResponse,
4949
Response,
50+
tx_timeout_as_ms,
5051
)
5152

5253

@@ -225,11 +226,8 @@ def run(self, query, parameters=None, mode=None, bookmarks=None,
225226
extra["tx_metadata"] = dict(metadata)
226227
except TypeError:
227228
raise TypeError("Metadata must be coercible to a dict")
228-
if timeout:
229-
try:
230-
extra["tx_timeout"] = int(1000 * timeout)
231-
except TypeError:
232-
raise TypeError("Timeout must be specified as a number of seconds")
229+
if timeout or (isinstance(timeout, (float, int)) and timeout == 0):
230+
extra["tx_timeout"] = tx_timeout_as_ms(timeout)
233231
fields = (query, parameters, extra)
234232
log.debug("[#%04X] C: RUN %s", self.local_port, " ".join(map(repr, fields)))
235233
if query.upper() == u"COMMIT":
@@ -277,12 +275,8 @@ def begin(self, mode=None, bookmarks=None, metadata=None, timeout=None,
277275
extra["tx_metadata"] = dict(metadata)
278276
except TypeError:
279277
raise TypeError("Metadata must be coercible to a dict")
280-
if timeout:
281-
try:
282-
extra["tx_timeout"] = int(1000 * timeout)
283-
except TypeError:
284-
raise TypeError("Timeout must be specified as a number of seconds")
285-
log.debug("[#%04X] C: BEGIN %r", self.local_port, extra)
278+
if timeout or (isinstance(timeout, (float, int)) and timeout == 0):
279+
extra["tx_timeout"] = tx_timeout_as_ms(timeout)
286280
self._append(b"\x11", (extra,), Response(self, "begin", **handlers))
287281

288282
def commit(self, **handlers):

neo4j/io/_bolt4.py

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
from neo4j.exceptions import (
3535
ConfigurationError,
3636
DatabaseUnavailable,
37-
DriverError,
3837
ForbiddenOnReadOnlyDatabase,
3938
Neo4jError,
4039
NotALeader,
@@ -48,6 +47,7 @@
4847
CommitResponse,
4948
InitResponse,
5049
Response,
50+
tx_timeout_as_ms,
5151
)
5252
from neo4j.io._bolt3 import (
5353
ServerStateManager,
@@ -178,11 +178,8 @@ def run(self, query, parameters=None, mode=None, bookmarks=None,
178178
extra["tx_metadata"] = dict(metadata)
179179
except TypeError:
180180
raise TypeError("Metadata must be coercible to a dict")
181-
if timeout:
182-
try:
183-
extra["tx_timeout"] = int(1000 * timeout)
184-
except TypeError:
185-
raise TypeError("Timeout must be specified as a number of seconds")
181+
if timeout or (isinstance(timeout, (float, int)) and timeout == 0):
182+
extra["tx_timeout"] = tx_timeout_as_ms(timeout)
186183
fields = (query, parameters, extra)
187184
log.debug("[#%04X] C: RUN %s", self.local_port, " ".join(map(repr, fields)))
188185
if query.upper() == u"COMMIT":
@@ -229,11 +226,8 @@ def begin(self, mode=None, bookmarks=None, metadata=None, timeout=None,
229226
extra["tx_metadata"] = dict(metadata)
230227
except TypeError:
231228
raise TypeError("Metadata must be coercible to a dict")
232-
if timeout:
233-
try:
234-
extra["tx_timeout"] = int(1000 * timeout)
235-
except TypeError:
236-
raise TypeError("Timeout must be specified as a number of seconds")
229+
if timeout or (isinstance(timeout, (float, int)) and timeout == 0):
230+
extra["tx_timeout"] = tx_timeout_as_ms(timeout)
237231
log.debug("[#%04X] C: BEGIN %r", self.local_port, extra)
238232
self._append(b"\x11", (extra,), Response(self, "begin", **handlers))
239233

@@ -490,12 +484,8 @@ def run(self, query, parameters=None, mode=None, bookmarks=None,
490484
extra["tx_metadata"] = dict(metadata)
491485
except TypeError:
492486
raise TypeError("Metadata must be coercible to a dict")
493-
if timeout:
494-
try:
495-
extra["tx_timeout"] = int(1000 * timeout)
496-
except TypeError:
497-
raise TypeError("Timeout must be specified as a number of "
498-
"seconds")
487+
if timeout or (isinstance(timeout, (float, int)) and timeout == 0):
488+
extra["tx_timeout"] = tx_timeout_as_ms(timeout)
499489
fields = (query, parameters, extra)
500490
log.debug("[#%04X] C: RUN %s", self.local_port,
501491
" ".join(map(repr, fields)))
@@ -525,11 +515,7 @@ def begin(self, mode=None, bookmarks=None, metadata=None, timeout=None,
525515
extra["tx_metadata"] = dict(metadata)
526516
except TypeError:
527517
raise TypeError("Metadata must be coercible to a dict")
528-
if timeout:
529-
try:
530-
extra["tx_timeout"] = int(1000 * timeout)
531-
except TypeError:
532-
raise TypeError("Timeout must be specified as a number of "
533-
"seconds")
518+
if timeout or (isinstance(timeout, (float, int)) and timeout == 0):
519+
extra["tx_timeout"] = tx_timeout_as_ms(timeout)
534520
log.debug("[#%04X] C: BEGIN %r", self.local_port, extra)
535521
self._append(b"\x11", (extra,), Response(self, "begin", **handlers))

neo4j/io/_common.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,3 +270,31 @@ def on_failure(self, metadata):
270270
class CommitResponse(Response):
271271

272272
pass
273+
274+
275+
def tx_timeout_as_ms(timeout: float) -> int:
276+
"""
277+
Round transaction timeout to milliseconds.
278+
279+
Values in (0, 1], else values are rounded using the built-in round()
280+
function (round n.5 values to nearest even).
281+
282+
:param timeout: timeout in seconds
283+
284+
:returns: timeout in milliseconds (rounded)
285+
286+
:raise ValueError: if timeout is negative
287+
"""
288+
try:
289+
timeout = float(timeout)
290+
except (TypeError, ValueError) as e:
291+
err_type = type(e)
292+
msg = "Timeout must be specified as a number of seconds"
293+
raise err_type(msg) from e
294+
ms = int(round(1000 * timeout))
295+
if ms == 0 and timeout > 0:
296+
# Special case for 0 < timeout < 0.5 ms.
297+
# This would be rounded to 0 ms, but the server interprets this as
298+
# infinite timeout. So we round to the smallest possible timeout: 1 ms.
299+
ms = 1
300+
return ms

neo4j/work/simple.py

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -281,10 +281,19 @@ def begin_transaction(self, metadata=None, timeout=None):
281281
282282
:param timeout:
283283
the transaction timeout in seconds.
284-
Transactions that execute longer than the configured timeout will be terminated by the database.
285-
This functionality allows to limit query/transaction execution time.
286-
Specified timeout overrides the default timeout configured in the database using ``dbms.transaction.timeout`` setting.
287-
Value should not represent a duration of zero or negative duration.
284+
Transactions that execute longer than the configured timeout will
285+
be terminated by the database.
286+
This functionality allows user code to limit query/transaction
287+
execution time.
288+
The specified timeout overrides the default timeout configured in
289+
the database using the ``db.transaction.timeout`` setting
290+
(``dbms.transaction.timeout`` before Neo4j 5.0).
291+
Values higher than ``db.transaction.timeout`` will be ignored and
292+
will fall back to the default for server versions between 4.2 and
293+
5.2 (inclusive).
294+
The value should not represent a negative duration.
295+
A ``0`` duration will make the transaction execute indefinitely.
296+
:data:`None` will use the default timeout configured on the server.
288297
:type timeout: int
289298
290299
:returns: A new transaction instance.
@@ -441,7 +450,21 @@ class Query:
441450
:type text: str
442451
:param metadata: metadata attached to the query.
443452
:type metadata: dict
444-
:param timeout: seconds.
453+
:param timeout:
454+
the transaction timeout in seconds.
455+
Transactions that execute longer than the configured timeout will
456+
be terminated by the database.
457+
This functionality allows user code to limit query/transaction
458+
execution time.
459+
The specified timeout overrides the default timeout configured in
460+
the database using the ``db.transaction.timeout`` setting
461+
(``dbms.transaction.timeout`` before Neo4j 5.0).
462+
Values higher than ``db.transaction.timeout`` will be ignored and
463+
will fall back to the default for server versions between 4.2 and
464+
5.2 (inclusive).
465+
The value should not represent a negative duration.
466+
A ``0`` duration will make the transaction execute indefinitely.
467+
:data:`None` will use the default timeout configured on the server.
445468
:type timeout: float or :const:`None`
446469
"""
447470
def __init__(self, text, metadata=None, timeout=None):
@@ -476,12 +499,19 @@ def count_people_tx(tx):
476499
477500
:param timeout:
478501
the transaction timeout in seconds.
479-
Transactions that execute longer than the configured timeout will be terminated by the database.
480-
This functionality allows to limit query/transaction execution time.
481-
Specified timeout overrides the default timeout configured in the database using ``dbms.transaction.timeout`` setting.
482-
Values higher than ``dbms.transaction.timeout`` will be ignored and
483-
will fall back to default (unless using Neo4j < 4.2).
484-
Value should not represent a duration of zero or negative duration.
502+
Transactions that execute longer than the configured timeout will
503+
be terminated by the database.
504+
This functionality allows user code to limit query/transaction
505+
execution time.
506+
The specified timeout overrides the default timeout configured in
507+
the database using the ``db.transaction.timeout`` setting
508+
(``dbms.transaction.timeout`` before Neo4j 5.0).
509+
Values higher than ``db.transaction.timeout`` will be ignored and
510+
will fall back to the default for server versions between 4.2 and
511+
5.2 (inclusive).
512+
The value should not represent a negative duration.
513+
A ``0`` duration will make the transaction execute indefinitely.
514+
:data:`None` will use the default timeout configured on the server.
485515
:type timeout: float or :const:`None`
486516
"""
487517

tests/unit/io/test_class_bolt3.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,56 @@ def test_hint_recv_timeout_seconds_gets_ignored(fake_socket_pair, recv_timeout):
110110
PoolConfig.max_connection_lifetime)
111111
connection.hello()
112112
sockets.client.settimeout.assert_not_called()
113+
114+
115+
@pytest.mark.parametrize(
116+
("func", "args", "extra_idx"),
117+
(
118+
("run", ("RETURN 1",), 2),
119+
("begin", (), 0),
120+
)
121+
)
122+
@pytest.mark.parametrize(
123+
("timeout", "res"),
124+
(
125+
(None, None),
126+
(0, 0),
127+
(0.1, 100),
128+
(0.001, 1),
129+
(1e-15, 1),
130+
(0.0005, 1),
131+
(0.0001, 1),
132+
(1.0015, 1002),
133+
(1.000499, 1000),
134+
(1.0025, 1002),
135+
(3.0005, 3000),
136+
(3.456, 3456),
137+
(1, 1000),
138+
(
139+
"foo",
140+
ValueError("Timeout must be specified as a number of seconds")
141+
),
142+
(
143+
[1, 2],
144+
TypeError("Timeout must be specified as a number of seconds")
145+
)
146+
)
147+
)
148+
def test_tx_timeout(fake_socket_pair, func, args, extra_idx, timeout, res):
149+
address = ("127.0.0.1", 7687)
150+
sockets = fake_socket_pair(address)
151+
sockets.server.send_message(0x70, {})
152+
connection = Bolt3(address, sockets.client, 0)
153+
func = getattr(connection, func)
154+
if isinstance(res, Exception):
155+
with pytest.raises(type(res), match=str(res)):
156+
func(*args, timeout=timeout)
157+
else:
158+
func(*args, timeout=timeout)
159+
connection.send_all()
160+
tag, fields = sockets.server.pop_message()
161+
extra = fields[extra_idx]
162+
if timeout is None:
163+
assert "tx_timeout" not in extra
164+
else:
165+
assert extra["tx_timeout"] == res

tests/unit/io/test_class_bolt4x0.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,56 @@ def test_hint_recv_timeout_seconds_gets_ignored(fake_socket_pair, recv_timeout):
197197
PoolConfig.max_connection_lifetime)
198198
connection.hello()
199199
sockets.client.settimeout.assert_not_called()
200+
201+
202+
@pytest.mark.parametrize(
203+
("func", "args", "extra_idx"),
204+
(
205+
("run", ("RETURN 1",), 2),
206+
("begin", (), 0),
207+
)
208+
)
209+
@pytest.mark.parametrize(
210+
("timeout", "res"),
211+
(
212+
(None, None),
213+
(0, 0),
214+
(0.1, 100),
215+
(0.001, 1),
216+
(1e-15, 1),
217+
(0.0005, 1),
218+
(0.0001, 1),
219+
(1.0015, 1002),
220+
(1.000499, 1000),
221+
(1.0025, 1002),
222+
(3.0005, 3000),
223+
(3.456, 3456),
224+
(1, 1000),
225+
(
226+
"foo",
227+
ValueError("Timeout must be specified as a number of seconds")
228+
),
229+
(
230+
[1, 2],
231+
TypeError("Timeout must be specified as a number of seconds")
232+
)
233+
)
234+
)
235+
def test_tx_timeout(fake_socket_pair, func, args, extra_idx, timeout, res):
236+
address = ("127.0.0.1", 7687)
237+
sockets = fake_socket_pair(address)
238+
sockets.server.send_message(0x70, {})
239+
connection = Bolt4x0(address, sockets.client, 0)
240+
func = getattr(connection, func)
241+
if isinstance(res, Exception):
242+
with pytest.raises(type(res), match=str(res)):
243+
func(*args, timeout=timeout)
244+
else:
245+
func(*args, timeout=timeout)
246+
connection.send_all()
247+
tag, fields = sockets.server.pop_message()
248+
extra = fields[extra_idx]
249+
if timeout is None:
250+
assert "tx_timeout" not in extra
251+
else:
252+
assert extra["tx_timeout"] == res

tests/unit/io/test_class_bolt4x1.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,56 @@ def test_hint_recv_timeout_seconds_gets_ignored(fake_socket_pair, recv_timeout):
210210
PoolConfig.max_connection_lifetime)
211211
connection.hello()
212212
sockets.client.settimeout.assert_not_called()
213+
214+
215+
@pytest.mark.parametrize(
216+
("func", "args", "extra_idx"),
217+
(
218+
("run", ("RETURN 1",), 2),
219+
("begin", (), 0),
220+
)
221+
)
222+
@pytest.mark.parametrize(
223+
("timeout", "res"),
224+
(
225+
(None, None),
226+
(0, 0),
227+
(0.1, 100),
228+
(0.001, 1),
229+
(1e-15, 1),
230+
(0.0005, 1),
231+
(0.0001, 1),
232+
(1.0015, 1002),
233+
(1.000499, 1000),
234+
(1.0025, 1002),
235+
(3.0005, 3000),
236+
(3.456, 3456),
237+
(1, 1000),
238+
(
239+
"foo",
240+
ValueError("Timeout must be specified as a number of seconds")
241+
),
242+
(
243+
[1, 2],
244+
TypeError("Timeout must be specified as a number of seconds")
245+
)
246+
)
247+
)
248+
def test_tx_timeout(fake_socket_pair, func, args, extra_idx, timeout, res):
249+
address = ("127.0.0.1", 7687)
250+
sockets = fake_socket_pair(address)
251+
sockets.server.send_message(0x70, {})
252+
connection = Bolt4x1(address, sockets.client, 0)
253+
func = getattr(connection, func)
254+
if isinstance(res, Exception):
255+
with pytest.raises(type(res), match=str(res)):
256+
func(*args, timeout=timeout)
257+
else:
258+
func(*args, timeout=timeout)
259+
connection.send_all()
260+
tag, fields = sockets.server.pop_message()
261+
extra = fields[extra_idx]
262+
if timeout is None:
263+
assert "tx_timeout" not in extra
264+
else:
265+
assert extra["tx_timeout"] == res

0 commit comments

Comments
 (0)