dsmq 1.2.3__py3-none-any.whl → 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dsmq/.server.py.swp ADDED
Binary file
dsmq/client.py CHANGED
@@ -8,6 +8,7 @@ _default_port = 30008
8
8
 
9
9
  _n_retries = 10
10
10
  _initial_retry = 0.01 # seconds
11
+ _shutdown_delay = 0.1 # seconds
11
12
 
12
13
 
13
14
  def connect(host=_default_host, port=_default_port, verbose=False):
@@ -68,6 +69,10 @@ class DSMQClientSideConnection:
68
69
  def shutdown_server(self):
69
70
  msg_dict = {"action": "shutdown", "topic": ""}
70
71
  self.websocket.send(json.dumps(msg_dict))
72
+ # Give the server time to wind down
73
+ time.sleep(_shutdown_delay)
71
74
 
72
75
  def close(self):
73
76
  self.websocket.close()
77
+ # Give the websocket time to wind down
78
+ time.sleep(_shutdown_delay)
dsmq/server.py CHANGED
@@ -5,16 +5,20 @@ import sys
5
5
  from threading import Thread
6
6
  import time
7
7
  from websockets.sync.server import serve as ws_serve
8
- from websockets.exceptions import ConnectionClosedError
8
+ from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK
9
9
 
10
10
  _default_host = "127.0.0.1"
11
11
  _default_port = 30008
12
- _n_retries = 5
13
- _first_retry = 0.01 # seconds
14
- _pause = 0.01 # seconds
15
- _time_to_live = 600.0 # seconds
12
+ _n_retries = 20
13
+ _first_retry = 0.005 # seconds
14
+ _time_to_live = 60.0 # seconds
16
15
 
16
+ # _db_name = ":memory:"
17
17
  _db_name = "file::memory:?cache=shared"
18
+ # May occasionally create files with this name.
19
+ # https://sqlite.org/inmemorydb.html
20
+ # "...parts of a temporary database might be flushed to disk if the
21
+ # database becomes large or if SQLite comes under memory pressure."
18
22
 
19
23
  # Make this global so it's easy to share
20
24
  dsmq_server = None
@@ -40,6 +44,14 @@ def serve(host=_default_host, port=_default_port, verbose=False):
40
44
 
41
45
  sqlite_conn = sqlite3.connect(_db_name)
42
46
  cursor = sqlite_conn.cursor()
47
+
48
+ # Tweak the connection to make it faster
49
+ # and keep long-term latency more predictable.
50
+ # cursor.execute("PRAGMA journal_mode = OFF")
51
+ cursor.execute("PRAGMA journal_mode = WAL")
52
+ # cursor.execute("PRAGMA synchronous = OFF")
53
+ # cursor.execute("PRAGMA secure_delete = OFF")
54
+
43
55
  cursor.execute("""
44
56
  CREATE TABLE IF NOT EXISTS messages (timestamp DOUBLE, topic TEXT, message TEXT)
45
57
  """)
@@ -52,12 +64,33 @@ CREATE TABLE IF NOT EXISTS messages (timestamp DOUBLE, topic TEXT, message TEXT)
52
64
  global dsmq_server
53
65
 
54
66
  # dsmq_server = ws_serve(request_handler, host, port)
55
- with ws_serve(request_handler, host, port) as dsmq_server:
56
- dsmq_server.serve_forever()
57
- if verbose:
58
- print()
59
- print(f"Server started at {host} on port {port}.")
60
- print("Waiting for clients...")
67
+ for i_retry in range(_n_retries):
68
+ try:
69
+ with ws_serve(request_handler, host, port) as dsmq_server:
70
+ dsmq_server.serve_forever()
71
+
72
+ if verbose:
73
+ print()
74
+ print(f"Server started at {host} on port {port}.")
75
+ print("Waiting for clients...")
76
+
77
+ break
78
+
79
+ except OSError:
80
+ # Catch the case where the address is already in use
81
+ if verbose:
82
+ print()
83
+ if i_retry < _n_retries - 1:
84
+ print(f"Couldn't start dsmq server on {host} on port {port}.")
85
+ print(f" Trying again ({i_retry}) ...")
86
+ else:
87
+ print()
88
+ print(f"Failed to start dsmq server on {host} on port {port}.")
89
+ print()
90
+ raise
91
+
92
+ wait_time = _first_retry * 2**i_retry
93
+ time.sleep(wait_time)
61
94
 
62
95
  sqlite_conn.close()
63
96
 
@@ -70,114 +103,120 @@ def request_handler(websocket):
70
103
  last_read_times = {}
71
104
  time_of_last_purge = time.time()
72
105
 
73
- for msg_text in websocket:
74
- msg = json.loads(msg_text)
75
- topic = msg["topic"]
76
- timestamp = time.time()
77
-
78
- if msg["action"] == "put":
79
- msg["timestamp"] = timestamp
106
+ try:
107
+ for msg_text in websocket:
108
+ msg = json.loads(msg_text)
109
+ topic = msg["topic"]
110
+ timestamp = time.time()
111
+
112
+ if msg["action"] == "put":
113
+ msg["timestamp"] = timestamp
114
+
115
+ # This block allows for multiple retries if the database
116
+ # is busy.
117
+ for i_retry in range(_n_retries):
118
+ try:
119
+ cursor.execute(
120
+ """
121
+ INSERT INTO messages (timestamp, topic, message)
122
+ VALUES (:timestamp, :topic, :message)
123
+ """,
124
+ (msg),
125
+ )
126
+ sqlite_conn.commit()
127
+ except sqlite3.OperationalError:
128
+ wait_time = _first_retry * 2**i_retry
129
+ time.sleep(wait_time)
130
+ continue
131
+ break
132
+
133
+ elif msg["action"] == "get":
134
+ try:
135
+ last_read_time = last_read_times[topic]
136
+ except KeyError:
137
+ last_read_times[topic] = client_creation_time
138
+ last_read_time = last_read_times[topic]
139
+ msg["last_read_time"] = last_read_time
140
+
141
+ # This block allows for multiple retries if the database
142
+ # is busy.
143
+ for i_retry in range(_n_retries):
144
+ try:
145
+ cursor.execute(
146
+ """
147
+ SELECT message,
148
+ timestamp
149
+ FROM messages,
150
+ (
151
+ SELECT MIN(timestamp) AS min_time
152
+ FROM messages
153
+ WHERE topic = :topic
154
+ AND timestamp > :last_read_time
155
+ ) a
156
+ WHERE topic = :topic
157
+ AND timestamp = a.min_time
158
+ """,
159
+ msg,
160
+ )
161
+ except sqlite3.OperationalError:
162
+ wait_time = _first_retry * 2**i_retry
163
+ time.sleep(wait_time)
164
+ continue
165
+ break
80
166
 
81
- # This block allows for multiple retries if the database
82
- # is busy.
83
- for i_retry in range(_n_retries):
84
167
  try:
85
- cursor.execute(
86
- """
87
- INSERT INTO messages (timestamp, topic, message)
88
- VALUES (:timestamp, :topic, :message)
89
- """,
90
- (msg),
91
- )
92
- sqlite_conn.commit()
93
- except sqlite3.OperationalError:
94
- wait_time = _first_retry * 2**i_retry
95
- time.sleep(wait_time)
96
- continue
97
- break
168
+ result = cursor.fetchall()[0]
169
+ message = result[0]
170
+ timestamp = result[1]
171
+ last_read_times[topic] = timestamp
172
+ except IndexError:
173
+ # Handle the case where no results are returned
174
+ message = ""
98
175
 
99
- elif msg["action"] == "get":
100
- try:
101
- last_read_time = last_read_times[topic]
102
- except KeyError:
103
- last_read_times[topic] = client_creation_time
104
- last_read_time = last_read_times[topic]
105
- msg["last_read_time"] = last_read_time
106
-
107
- # This block allows for multiple retries if the database
108
- # is busy.
109
- for i_retry in range(_n_retries):
176
+ websocket.send(json.dumps({"message": message}))
177
+ elif msg["action"] == "shutdown":
178
+ # Run this from a separate thread to prevent deadlock
179
+ global dsmq_server
180
+
181
+ def shutdown_gracefully(server_to_shutdown):
182
+ server_to_shutdown.shutdown()
183
+
184
+ filenames = os.listdir()
185
+ for filename in filenames:
186
+ if filename[: len(_db_name)] == _db_name:
187
+ try:
188
+ os.remove(filename)
189
+ except FileNotFoundError:
190
+ pass
191
+
192
+ Thread(target=shutdown_gracefully, args=(dsmq_server,)).start()
193
+ break
194
+ else:
195
+ raise RuntimeWarning(
196
+ "dsmq client action must either be 'put', 'get', or 'shutdown'"
197
+ )
198
+
199
+ # Periodically clean out messages from the queue that are
200
+ # past their sell buy date.
201
+ # This operation is pretty fast. I clock it at 12 us on my machine.
202
+ if time.time() - time_of_last_purge > _time_to_live:
110
203
  try:
111
204
  cursor.execute(
112
205
  """
113
- SELECT message,
114
- timestamp
115
- FROM messages,
116
- (
117
- SELECT MIN(timestamp) AS min_time
118
- FROM messages
119
- WHERE topic = :topic
120
- AND timestamp > :last_read_time
121
- ) a
122
- WHERE topic = :topic
123
- AND timestamp = a.min_time
206
+ DELETE FROM messages
207
+ WHERE timestamp < :time_threshold
124
208
  """,
125
- msg,
209
+ {"time_threshold": time_of_last_purge},
126
210
  )
211
+ sqlite_conn.commit()
212
+ time_of_last_purge = time.time()
127
213
  except sqlite3.OperationalError:
128
- wait_time = _first_retry * 2**i_retry
129
- time.sleep(wait_time)
130
- continue
131
- break
132
-
133
- try:
134
- result = cursor.fetchall()[0]
135
- message = result[0]
136
- timestamp = result[1]
137
- last_read_times[topic] = timestamp
138
- except IndexError:
139
- # Handle the case where no results are returned
140
- message = ""
141
-
142
- try:
143
- websocket.send(json.dumps({"message": message}))
144
- except ConnectionClosedError:
145
- pass
146
- elif msg["action"] == "shutdown":
147
- # Run this from a separate thread to prevent deadlock
148
- global dsmq_server
149
-
150
- def shutdown_gracefully(server_to_shutdown):
151
- server_to_shutdown.shutdown()
152
-
153
- filenames = os.listdir()
154
- for filename in filenames:
155
- if filename[: len(_db_name)] == _db_name:
156
- try:
157
- os.remove(filename)
158
- except FileNotFoundError:
159
- pass
160
-
161
- Thread(target=shutdown_gracefully, args=(dsmq_server,)).start()
162
- break
163
- else:
164
- raise RuntimeWarning(
165
- "dsmq client action must either be 'put', 'get', or 'shutdown'"
166
- )
167
-
168
- # Periodically clean out messages from the queue that are
169
- # past their sell buy date.
170
- # This operation is pretty fast. I clock it at 12 us on my machine.
171
- if time.time() - time_of_last_purge > _time_to_live:
172
- cursor.execute(
173
- """
174
- DELETE FROM messages
175
- WHERE timestamp < :time_threshold
176
- """,
177
- {"time_threshold": time_of_last_purge},
178
- )
179
- sqlite_conn.commit()
180
- time_of_last_purge = time.time()
214
+ # Database may be locked. Try again next time.
215
+ pass
216
+ except (ConnectionClosedError, ConnectionClosedOK):
217
+ # Something happened on the other end and this handler
218
+ # is no longer needed.
219
+ pass
181
220
 
182
221
  sqlite_conn.close()
183
222
 
Binary file
@@ -1,6 +1,5 @@
1
1
  import multiprocessing as mp
2
2
  import time
3
- from websockets.exceptions import ConnectionClosed
4
3
  from dsmq.server import serve
5
4
  from dsmq.client import connect
6
5
 
@@ -0,0 +1,179 @@
1
+ import multiprocessing as mp
2
+ import time
3
+
4
+ from dsmq.server import serve
5
+ from dsmq.client import connect
6
+
7
+ host = "127.0.0.1"
8
+ port = 30303
9
+ verbose = False
10
+
11
+ _pause = 0.01
12
+ _very_long_pause = 1.0
13
+
14
+ _n_iter = int(1e3)
15
+ _n_long_char = int(1e4)
16
+
17
+ _short_msg = "q"
18
+ _long_msg = str(["q"] * _n_long_char)
19
+
20
+ _test_topic = "test"
21
+
22
+
23
+ def main():
24
+ print()
25
+ print("dsmq timing measurements")
26
+
27
+ time_short_writes()
28
+ time_long_writes()
29
+ time_empty_reads()
30
+ time_short_reads()
31
+ time_long_reads()
32
+
33
+
34
+ def time_short_writes():
35
+ condition = "short write"
36
+
37
+ duration, duration_close = time_writes(msg=_short_msg, n_iter=1)
38
+
39
+ print()
40
+ print(f"Time for first {condition} [including closing]")
41
+ print(f" {int(duration)} μs [{int(duration_close)} μs]")
42
+
43
+ avg_duration, avg_duration_close = time_writes(msg=_short_msg, n_iter=_n_iter)
44
+
45
+ print(f"Average time for a {condition} [including closing]")
46
+ print(f" {int(avg_duration)} μs [{int(avg_duration_close)} μs]")
47
+
48
+
49
+ def time_long_writes():
50
+ duration, duration_close = time_writes(msg=_long_msg, n_iter=1)
51
+
52
+ condition = "long write"
53
+ print()
54
+ print(f"Time for first {condition} [including closing]")
55
+ print(f" {int(duration)} μs [{int(duration_close)} μs]")
56
+
57
+ avg_duration, avg_duration_close = time_writes(msg=_long_msg, n_iter=_n_iter)
58
+
59
+ condition = f"long write ({_n_long_char} characters)"
60
+ print(f"Average time for a {condition} [including closing]")
61
+ print(f" {int(avg_duration)} μs [{int(avg_duration_close)} μs]")
62
+
63
+ condition = "long write (per 1000 characters)"
64
+ print(f"Average time for a {condition} [including closing]")
65
+ print(
66
+ f" {int(1000 * avg_duration / _n_long_char)} μs "
67
+ + f"[{int(1000 * avg_duration_close / _n_long_char)}] μs"
68
+ )
69
+
70
+
71
+ def time_writes(msg="message", n_iter=1):
72
+ p_server = mp.Process(target=serve, args=(host, port, verbose))
73
+ p_server.start()
74
+ time.sleep(_pause)
75
+ write_client = connect(host, port)
76
+
77
+ start_time = time.time()
78
+ for _ in range(n_iter):
79
+ write_client.put(_test_topic, msg)
80
+ avg_duration = 1e6 * (time.time() - start_time) / n_iter # microseconds
81
+
82
+ write_client.shutdown_server()
83
+ write_client.close()
84
+
85
+ p_server.join(_very_long_pause)
86
+ if p_server.is_alive():
87
+ print(" Doing a hard shutdown on mq server")
88
+ p_server.kill()
89
+ avg_duration_close = 1e6 * (time.time() - start_time) / n_iter # microseconds
90
+
91
+ return avg_duration, avg_duration_close
92
+
93
+
94
+ def time_empty_reads():
95
+ condition = "empty read"
96
+
97
+ duration, duration_close = time_reads(msg=None, n_iter=1)
98
+
99
+ print()
100
+ print(f"Time for first {condition} [including closing]")
101
+ print(f" {int(duration)} μs [{int(duration_close)} μs]")
102
+
103
+ avg_duration, avg_duration_close = time_reads(msg=None, n_iter=_n_iter)
104
+
105
+ print(f"Average time for a {condition} [including closing]")
106
+ print(f" {int(avg_duration)} μs [{int(avg_duration_close)} μs]")
107
+
108
+
109
+ def time_short_reads():
110
+ condition = "short read"
111
+
112
+ duration, duration_close = time_reads(msg=_short_msg, n_iter=1)
113
+
114
+ print()
115
+ print(f"Time for first {condition} [including closing]")
116
+ print(f" {int(duration)} μs [{int(duration_close)} μs]")
117
+
118
+ avg_duration, avg_duration_close = time_reads(msg=_short_msg, n_iter=_n_iter)
119
+
120
+ print(f"Average time for a {condition} [including closing]")
121
+ print(f" {int(avg_duration)} μs [{int(avg_duration_close)} μs]")
122
+
123
+
124
+ def time_long_reads():
125
+ condition = f"long read ({_n_long_char} characters)"
126
+
127
+ duration, duration_close = time_reads(msg=_long_msg, n_iter=1)
128
+
129
+ print()
130
+ print(f"Time for first {condition} [including closing]")
131
+ print(f" {int(duration)} μs [{int(duration_close)} μs]")
132
+
133
+ avg_duration, avg_duration_close = time_reads(msg=_long_msg, n_iter=_n_iter)
134
+
135
+ print(f"Average time for a {condition} [including closing]")
136
+ print(f" {int(avg_duration)} μs [{int(avg_duration_close)} μs]")
137
+
138
+ condition = "long read (per 1000 characters)"
139
+ print(f"Average time for a {condition} [including closing]")
140
+ print(
141
+ f" {int(1000 * avg_duration / _n_long_char)} μs "
142
+ + f"[{int(1000 * avg_duration_close / _n_long_char)}] μs"
143
+ )
144
+
145
+
146
+ def time_reads(msg=None, n_iter=1):
147
+ p_server = mp.Process(target=serve, args=(host, port, verbose))
148
+ p_server.start()
149
+ time.sleep(_pause)
150
+ # write_client = connect(host, port)
151
+ read_client = connect(host, port)
152
+
153
+ if msg is not None:
154
+ for _ in range(n_iter):
155
+ read_client.put(_test_topic, msg)
156
+
157
+ start_time = time.time()
158
+ for _ in range(n_iter):
159
+ msg = read_client.get(_test_topic)
160
+
161
+ avg_duration = 1e6 * (time.time() - start_time) / n_iter # microseconds
162
+
163
+ read_client.shutdown_server()
164
+ # write_client.close()
165
+ read_client.close()
166
+
167
+ p_server.join(_very_long_pause)
168
+
169
+ if p_server.is_alive():
170
+ print(" Doing a hard shutdown on mq server")
171
+ p_server.kill()
172
+
173
+ avg_duration_close = 1e6 * (time.time() - start_time) / n_iter # microseconds
174
+
175
+ return avg_duration, avg_duration_close
176
+
177
+
178
+ if __name__ == "__main__":
179
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dsmq
3
- Version: 1.2.3
3
+ Version: 1.3.0
4
4
  Summary: A dead simple message queue
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -178,3 +178,8 @@ Run all the tests in `src/dsmq/tests/` with pytest, for example
178
178
  ```
179
179
  uv run pytest
180
180
  ```
181
+
182
+ # Performance characterization
183
+
184
+ Time typical operations on your system with the script at
185
+ `src/dsmq/tests/performance_suite.py`
@@ -0,0 +1,15 @@
1
+ dsmq/.server.py.swp,sha256=RLjzmw9FyOQY73HurM9PEjp4meAgVEZIN8M92OqgBzw,28672
2
+ dsmq/__init__.py,sha256=YCgbnQAk8YbtHRyMcU0v2O7RdRhPhlT-vS_q40a7Q6g,50
3
+ dsmq/client.py,sha256=_JJlDFTw1-VsRNsm-grYvexrWkkh50n62Q6-aGWum5Q,2547
4
+ dsmq/demo.py,sha256=K53cC5kN7K4kNJlPq7c5OTIMHRCKTo9hYX2aIos57rU,542
5
+ dsmq/example_get_client.py,sha256=PvAsDGEAH1kVBifLVg2rx8ZxnAZmvzVCvZq13VgpLds,301
6
+ dsmq/example_put_client.py,sha256=QxDc3i7KAjjhpwxRRpI0Ke5KTNSPuBf9kkcGyTvUEaw,353
7
+ dsmq/server.py,sha256=Ej6iQ1aw4LVHyHvdeZxxwSZS3VBIQP0qsFstuo0hwnY,8487
8
+ dsmq/tests/.performance_suite.py.swp,sha256=D3B86JpgBIYDE0at6nG2Uw9WWASFxp12mB9zlNXYPbA,24576
9
+ dsmq/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ dsmq/tests/integration_test.py,sha256=dLsQGCmpXv4zRb93TriccH7TbUyD9MHcLckAQqfDOK4,5980
11
+ dsmq/tests/performance_suite.py,sha256=E59zB2ZvM8V5f8RxaB7p-Kehqyhrgsl0sXuy7g74BaI,5218
12
+ dsmq-1.3.0.dist-info/METADATA,sha256=bZRJ0Oz5Vnavl4AU9bO2f7TuktSpUOJQ9hyx7Yusne4,4859
13
+ dsmq-1.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
+ dsmq-1.3.0.dist-info/licenses/LICENSE,sha256=3Yu1mAp5VsKmnDtzkiOY7BdmrLeNwwZ3t6iWaLnlL0Y,1071
15
+ dsmq-1.3.0.dist-info/RECORD,,
@@ -1,12 +0,0 @@
1
- dsmq/__init__.py,sha256=YCgbnQAk8YbtHRyMcU0v2O7RdRhPhlT-vS_q40a7Q6g,50
2
- dsmq/client.py,sha256=p6irQZOE4b2fpTUwSrEUraPmrJzvT8QSU01ak9qpGCQ,2351
3
- dsmq/demo.py,sha256=K53cC5kN7K4kNJlPq7c5OTIMHRCKTo9hYX2aIos57rU,542
4
- dsmq/example_get_client.py,sha256=PvAsDGEAH1kVBifLVg2rx8ZxnAZmvzVCvZq13VgpLds,301
5
- dsmq/example_put_client.py,sha256=QxDc3i7KAjjhpwxRRpI0Ke5KTNSPuBf9kkcGyTvUEaw,353
6
- dsmq/server.py,sha256=HkV1yTYe0u_P6TZVAS_wBkT6TDLySfmdZPVvt-VyeF0,6219
7
- dsmq/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- dsmq/tests/integration_test.py,sha256=lC97LAzdQixv75OwjqjKTvYnSZpsP0zuzFP8ocUnjl8,6031
9
- dsmq-1.2.3.dist-info/METADATA,sha256=H3t1g1Hs68VsheHaCqzrp2bs1ekwzUM5ajC7bzgjH34,4730
10
- dsmq-1.2.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
- dsmq-1.2.3.dist-info/licenses/LICENSE,sha256=3Yu1mAp5VsKmnDtzkiOY7BdmrLeNwwZ3t6iWaLnlL0Y,1071
12
- dsmq-1.2.3.dist-info/RECORD,,
File without changes