Skip to content

Commit 7f515ff

Browse files
d-w-moorealanking
authored andcommitted
[#532,#564,#569] fix stored connections to match desired connection timeout.
Note also: a connection_timeout of value None is now allowed, and effectively disables the timeout.
1 parent d70f151 commit 7f515ff

5 files changed

Lines changed: 124 additions & 12 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,12 @@ session object:
184184
>>> session.connection_timeout = 300
185185
```
186186

187-
This will set the timeout to five minutes for any associated
188-
connections.
187+
This will set the timeout to five minutes for any associated connections.
188+
189+
Timeouts are either a positive `int` or `float` with units of seconds, or `None`, all in accordance with their
190+
meaning in the context of the socket module. A value of `None` indicates timeouts are effectively
191+
infinite in value, i.e. turned off. Setting a session's `connection_timeout` value to 0 is disallowed
192+
because this would cause the socket to enter non-blocking mode.
189193

190194
Session objects and cleanup
191195
---------------------------

irods/pool.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ def method_(self,*s,**kw):
2222

2323
DEFAULT_APPLICATION_NAME = 'python-irodsclient'
2424

25+
def _adjust_timeout_to_pool_default(conn):
26+
set_timeout = conn.socket.gettimeout()
27+
desired_value = conn.pool.connection_timeout
28+
if desired_value == set_timeout:
29+
return
30+
conn.socket.settimeout(desired_value)
31+
2532
class Pool(object):
2633

2734
def __init__(self, account, application_name='', connection_refresh_time=-1, session = None):
@@ -57,6 +64,7 @@ def _conn(self, conn_): setattr( self._thread_local, "_conn", conn_)
5764

5865
@attribute_from_return_value("_conn")
5966
def get_connection(self):
67+
new_conn = False
6068
with self._lock:
6169
try:
6270
conn = self.idle.pop()
@@ -73,9 +81,11 @@ def get_connection(self):
7381
# code more predictable as we are not relying on when garbage collector is called
7482
conn.disconnect()
7583
conn = Connection(self, self.account)
84+
new_conn = True
7685
logger.debug("Created new connection with id: {}".format(id(conn)))
7786
except KeyError:
7887
conn = Connection(self, self.account)
88+
new_conn = True
7989
logger.debug("No connection found in idle set. Created a new connection with id: {}".format(id(conn)))
8090

8191
self.active.add(conn)
@@ -87,6 +97,11 @@ def get_connection(self):
8797

8898
logger.debug("Adding connection with id {} to active set".format(id(conn)))
8999

100+
# If the connection we're about to make active was cached, it already has a socket object internal to it,
101+
# so we potentially have to modify it to have the desired timeout.
102+
if not new_conn:
103+
_adjust_timeout_to_pool_default(conn)
104+
90105
logger.debug('num active: {}'.format(len(self.active)))
91106
logger.debug('num idle: {}'.format(len(self.idle)))
92107

irods/session.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import errno
66
import json
77
import logging
8+
from numbers import Number
89
import os
910
import threading
1011
import weakref
@@ -349,9 +350,20 @@ def connection_timeout(self):
349350

350351
@connection_timeout.setter
351352
def connection_timeout(self, seconds):
353+
if seconds == 0:
354+
exc = ValueError("Setting an iRODS connection_timeout to 0 seconds would make it non-blocking.")
355+
raise exc
356+
elif isinstance(seconds, Number):
357+
if seconds < 0 or str(seconds) == 'nan' or str(abs(seconds)) == 'inf':
358+
exc = ValueError("The iRODS connection_timeout may not be assigned a negative or otherwise rogue value (eg: NaN, Inf).")
359+
raise exc
360+
elif seconds is None:
361+
pass
362+
else:
363+
exc = ValueError("The iRODS connection_timeout must be assigned a positive int, positive float, or None.")
364+
raise exc
352365
self._cached_connection_timeout = seconds
353-
if seconds is not None:
354-
self.pool.connection_timeout = seconds
366+
self.pool.connection_timeout = seconds
355367

356368
@staticmethod
357369
def get_irods_password_file():

irods/test/connection_test.py

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import unittest
77
from irods.exception import NetworkException
88
import irods.test.helpers as helpers
9-
9+
from irods.test.helpers import (server_side_sleep, temporarily_assign_attribute as temp_setter)
1010

1111
class TestConnections(unittest.TestCase):
1212

@@ -105,22 +105,79 @@ def test_that_connection_timeout_works__issue_377(self):
105105
if local_file:
106106
os.unlink(local_file.name)
107107

108-
def assert_timeout_value_propagated_to_socket(self, session, timeout_value):
108+
def _assert_timeout_value_is_propagated_to_all_sockets__issue_569(self, session, expected_timeout_value = 'POOL_TIMEOUT_SETTING'):
109+
pool = session.pool
110+
new_conn = None
111+
if expected_timeout_value == 'POOL_TIMEOUT_SETTING':
112+
expected_timeout_value = pool.connection_timeout
113+
connections = set()
114+
# make sure idle pool is not empty
109115
session.collections.get(helpers.home_collection(session))
110-
conn = next(iter(session.pool.idle))
111-
self.assertEqual(conn.socket.gettimeout(), timeout_value)
116+
# On any connections thus far created, check that their internal socket objects are set to the expected timeout value.
117+
try:
118+
# Peel connections off the idle pool and check each for the expected timeout value, but don't release them to that pool yet.
119+
while (pool.idle):
120+
# Peel a connection (guaranteed newly-allocated for purposes of this test) and check for the proper timeout.
121+
conn = pool.get_connection()
122+
connections |= {conn}
123+
self.assertEqual(conn.socket.gettimeout(), expected_timeout_value)
124+
125+
# Get an additional connection while idle pool is empty; this way, we know it to be newly-allocated.
126+
new_conn = pool.get_connection()
127+
128+
# Check the expected timeout applies to the newly-allocated connection
129+
self.assertEqual(new_conn.socket.gettimeout(), expected_timeout_value)
130+
131+
finally:
132+
# Release and destroy the connection that was newly-allocated for this test.
133+
if new_conn:
134+
new_conn.release(destroy = True)
135+
# Release connections that had been cached, by the same normal mechanism the API endpoints indirectly employ.
136+
for conn in connections:
137+
pool.release_connection(conn)
112138

113139
def test_connection_timeout_parameter_in_session_init__issue_377(self):
114140
timeout = 1.0
115141
sess = helpers.make_session(connection_timeout = timeout)
116-
self.assert_timeout_value_propagated_to_socket(sess, timeout)
142+
self._assert_timeout_value_is_propagated_to_all_sockets__issue_569(sess, timeout)
117143

118144
def test_assigning_session_connection_timeout__issue_377(self):
119-
sess = self.sess
145+
sess = helpers.make_session()
120146
for timeout in (999999, None):
121147
sess.connection_timeout = timeout
122-
sess.cleanup()
123-
self.assert_timeout_value_propagated_to_socket(sess, timeout)
148+
self._assert_timeout_value_is_propagated_to_all_sockets__issue_569(sess, timeout)
149+
150+
def test_assigning_session_connection_timeout_to_invalid_values__issue_569(self):
151+
sess = helpers.make_session()
152+
DESIRED_TIMEOUT = 64.25
153+
sess.connection_timeout = DESIRED_TIMEOUT
154+
# Test our desired connection pool default timeout has taken hold.
155+
self.assertEqual(sess.connection_timeout, DESIRED_TIMEOUT)
156+
157+
# Test that bad timeout values are met with an exception.
158+
for value in (float('NaN'), float('Inf'), -float('Inf'), -1, 0, 0.0, "banana"):
159+
with self.assertRaises(ValueError):
160+
sess.connection_timeout = value
161+
162+
# Test previously set value is unaffected
163+
self.assertEqual(sess.connection_timeout, DESIRED_TIMEOUT)
164+
165+
def test_assigning_session_connection_timeout__issue_569(self):
166+
sess = helpers.make_session()
167+
old_timeout = sess.connection_timeout
168+
169+
with temp_setter(sess, 'connection_timeout',1.0):
170+
# verify we can reproduce a NetworkException from a server timeout
171+
with self.assertRaises(NetworkException):
172+
server_side_sleep(sess,2.5)
173+
# temporarily suspend timeouts on a session
174+
with temp_setter(sess, 'connection_timeout',None):
175+
server_side_sleep(sess,2.5)
176+
# temporarily increase (from 1.0 to 4) the timeout on a session
177+
with temp_setter(sess, 'connection_timeout',4):
178+
server_side_sleep(sess,2.5)
179+
self.assertEqual(old_timeout, sess.connection_timeout)
180+
self._assert_timeout_value_is_propagated_to_all_sockets__issue_569(sess, old_timeout)
124181

125182
if __name__ == '__main__':
126183
# let the tests find the parent irods lib

irods/test/helpers.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import json
1818
import sys
1919
import irods.client_configuration as config
20+
import irods.rule
2021
from irods.session import iRODSSession
2122
from irods.message import (iRODSMessage, IRODS_VERSION)
2223
from irods.password_obfuscation import encode
@@ -377,3 +378,26 @@ def enableLogging(logger, handlerType, args, level_ = logging.INFO):
377378
logger.removeHandler(h)
378379

379380

381+
class _unlikely_value: pass
382+
383+
@contextlib.contextmanager
384+
def temporarily_assign_attribute(target, attr, value, not_set_indicator = _unlikely_value()):
385+
save = not_set_indicator
386+
try:
387+
save = getattr(target, attr, not_set_indicator)
388+
setattr(target, attr, value)
389+
yield
390+
finally:
391+
if save != not_set_indicator:
392+
setattr(target, attr, save)
393+
else:
394+
delattr(target, attr)
395+
396+
# Implement a server-side wait that ensures no TCP communication from server end for a given interval.
397+
# Useful to test the effect of socket inactivity on a client. See python-irodsclient issue #569
398+
def server_side_sleep(session, seconds):
399+
# Round floating-point seconds to nearest integer + microseconds figure, required by msiSleep.
400+
int_, frac_ = [int(_) for _ in divmod(seconds * 1.0e6 + 0.5, 1.0e6)]
401+
rule_code = "msiSleep('{}','{}')".format(int_,frac_)
402+
# Call the msiSleep microservice.
403+
irods.rule.Rule(session, body = rule_code, instance_name = 'irods_rule_engine_plugin-irods_rule_language-instance').execute()

0 commit comments

Comments
 (0)