singleserver 0.1.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.
- singleserver/__init__.py +42 -0
- singleserver/client.py +305 -0
- singleserver/lock.py +282 -0
- singleserver/process.py +489 -0
- singleserver/server.py +432 -0
- singleserver-0.1.0.dist-info/METADATA +185 -0
- singleserver-0.1.0.dist-info/RECORD +9 -0
- singleserver-0.1.0.dist-info/WHEEL +4 -0
- singleserver-0.1.0.dist-info/licenses/LICENSE +21 -0
singleserver/server.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main SingleServer class that ties together lock, process, and client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import atexit
|
|
8
|
+
import logging
|
|
9
|
+
import signal
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .client import ServerClient
|
|
17
|
+
from .lock import SocketLock
|
|
18
|
+
from .process import ProcessOwner
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SingleServer:
|
|
24
|
+
"""
|
|
25
|
+
Manages a singleton server process across multiple workers.
|
|
26
|
+
|
|
27
|
+
Uses atomic socket binding for coordination - only one process can become
|
|
28
|
+
the "owner" and run the server, others become clients.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
# Define the server
|
|
32
|
+
datasette = SingleServer(
|
|
33
|
+
name="datasette",
|
|
34
|
+
command=["datasette", "serve", "data.db", "-p", "{port}"],
|
|
35
|
+
port=8765,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Connect (starts if needed, connects if already running)
|
|
39
|
+
with datasette.connect() as client:
|
|
40
|
+
response = client.get("/data/my_table.json")
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
name: str,
|
|
46
|
+
command: list[str],
|
|
47
|
+
port: int | None = None,
|
|
48
|
+
socket: str | Path | None = None,
|
|
49
|
+
lock_port: int | None = None,
|
|
50
|
+
lock_socket: str | Path | None = None,
|
|
51
|
+
health_check_url: str = "/",
|
|
52
|
+
health_check_interval: float = 5.0,
|
|
53
|
+
startup_timeout: float = 30.0,
|
|
54
|
+
restart_on_failure: bool = True,
|
|
55
|
+
max_restarts: int = 3,
|
|
56
|
+
restart_delay: float = 1.0,
|
|
57
|
+
env: dict[str, str] | None = None,
|
|
58
|
+
cwd: str | Path | None = None,
|
|
59
|
+
stdout: str | Path | None = None,
|
|
60
|
+
stderr: str | Path | None = None,
|
|
61
|
+
shutdown_timeout: float = 10.0,
|
|
62
|
+
shutdown_signal: int = signal.SIGTERM,
|
|
63
|
+
):
|
|
64
|
+
"""
|
|
65
|
+
Initialize the SingleServer.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
name: Identifier for logging/debugging.
|
|
69
|
+
command: Command to run. Can include {port} placeholder.
|
|
70
|
+
port: TCP port the server will listen on.
|
|
71
|
+
socket: Unix socket path the server will listen on.
|
|
72
|
+
lock_port: Separate port for the coordination lock (defaults to port - 1).
|
|
73
|
+
lock_socket: Separate socket path for coordination lock.
|
|
74
|
+
health_check_url: URL path to check for server readiness.
|
|
75
|
+
health_check_interval: Seconds between health checks when running.
|
|
76
|
+
startup_timeout: Max seconds to wait for server to become ready.
|
|
77
|
+
restart_on_failure: Whether to restart if server dies unexpectedly.
|
|
78
|
+
max_restarts: Maximum restart attempts before giving up.
|
|
79
|
+
restart_delay: Seconds to wait between restart attempts.
|
|
80
|
+
env: Additional environment variables for the server process.
|
|
81
|
+
cwd: Working directory for the server process.
|
|
82
|
+
stdout: Where to redirect stdout ("inherit", "null", or file path).
|
|
83
|
+
stderr: Where to redirect stderr ("inherit", "null", "stdout", or file path).
|
|
84
|
+
shutdown_timeout: Seconds to wait for graceful shutdown.
|
|
85
|
+
shutdown_signal: Signal to send for shutdown.
|
|
86
|
+
"""
|
|
87
|
+
if port is None and socket is None:
|
|
88
|
+
raise ValueError("Must provide either port or socket")
|
|
89
|
+
|
|
90
|
+
self.name = name
|
|
91
|
+
self._command_template = command
|
|
92
|
+
self.port = port
|
|
93
|
+
self.socket_path = Path(socket) if socket else None
|
|
94
|
+
|
|
95
|
+
# Determine lock address (separate from server address)
|
|
96
|
+
if lock_port is not None or lock_socket is not None:
|
|
97
|
+
self._lock_port = lock_port
|
|
98
|
+
self._lock_socket = Path(lock_socket) if lock_socket else None
|
|
99
|
+
elif port is not None:
|
|
100
|
+
# Use a separate lock port by default (ensure it stays within valid range)
|
|
101
|
+
self._lock_port = port + 10000 if port + 10000 <= 65535 else port - 10000
|
|
102
|
+
self._lock_socket = None
|
|
103
|
+
else:
|
|
104
|
+
# Use a separate lock socket
|
|
105
|
+
self._lock_port = None
|
|
106
|
+
self._lock_socket = Path(str(socket) + ".lock")
|
|
107
|
+
|
|
108
|
+
self.health_check_url = health_check_url
|
|
109
|
+
self.health_check_interval = health_check_interval
|
|
110
|
+
self.startup_timeout = startup_timeout
|
|
111
|
+
self.restart_on_failure = restart_on_failure
|
|
112
|
+
self.max_restarts = max_restarts
|
|
113
|
+
self.restart_delay = restart_delay
|
|
114
|
+
self.env = env
|
|
115
|
+
self.cwd = cwd
|
|
116
|
+
self.stdout = stdout
|
|
117
|
+
self.stderr = stderr
|
|
118
|
+
self.shutdown_timeout = shutdown_timeout
|
|
119
|
+
self.shutdown_signal = shutdown_signal
|
|
120
|
+
|
|
121
|
+
self._lock: SocketLock | None = None
|
|
122
|
+
self._owner: ProcessOwner | None = None
|
|
123
|
+
self._is_owner = False
|
|
124
|
+
self._client: ServerClient | None = None
|
|
125
|
+
self._cleanup_registered = False
|
|
126
|
+
self._state_lock = threading.Lock()
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def command(self) -> list[str]:
|
|
130
|
+
"""Build the command with placeholders replaced."""
|
|
131
|
+
result = []
|
|
132
|
+
for part in self._command_template:
|
|
133
|
+
if "{port}" in part:
|
|
134
|
+
part = part.replace("{port}", str(self.port))
|
|
135
|
+
if "{socket}" in part:
|
|
136
|
+
part = part.replace("{socket}", str(self.socket_path))
|
|
137
|
+
result.append(part)
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def is_owner(self) -> bool:
|
|
142
|
+
"""Return True if this process owns the server."""
|
|
143
|
+
return self._is_owner
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def is_connected(self) -> bool:
|
|
147
|
+
"""Return True if connected to the server (as owner or client)."""
|
|
148
|
+
return self._client is not None and self._client.is_ready
|
|
149
|
+
|
|
150
|
+
def _create_health_check(self) -> Callable[[], bool]:
|
|
151
|
+
"""Create a health check function for the process owner."""
|
|
152
|
+
|
|
153
|
+
def check() -> bool:
|
|
154
|
+
try:
|
|
155
|
+
client = ServerClient(
|
|
156
|
+
host="127.0.0.1",
|
|
157
|
+
port=self.port,
|
|
158
|
+
socket_path=self.socket_path,
|
|
159
|
+
health_check_url=self.health_check_url,
|
|
160
|
+
startup_timeout=2.0,
|
|
161
|
+
)
|
|
162
|
+
return bool(client.check_ready())
|
|
163
|
+
except Exception:
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
return check
|
|
167
|
+
|
|
168
|
+
def _register_cleanup(self) -> None:
|
|
169
|
+
"""Register cleanup handlers."""
|
|
170
|
+
if self._cleanup_registered:
|
|
171
|
+
return
|
|
172
|
+
self._cleanup_registered = True
|
|
173
|
+
atexit.register(self._cleanup)
|
|
174
|
+
|
|
175
|
+
# Also handle signals for clean shutdown
|
|
176
|
+
def signal_handler(signum: int, frame: Any) -> None:
|
|
177
|
+
self._cleanup()
|
|
178
|
+
# Re-raise signal for default handling
|
|
179
|
+
signal.signal(signum, signal.SIG_DFL)
|
|
180
|
+
signal.raise_signal(signum)
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
184
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
185
|
+
except ValueError:
|
|
186
|
+
# Can't set signal handlers from non-main thread
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
def _cleanup(self) -> None:
|
|
190
|
+
"""Clean up resources on exit."""
|
|
191
|
+
logger.debug(f"Cleaning up SingleServer {self.name}")
|
|
192
|
+
|
|
193
|
+
if self._owner:
|
|
194
|
+
try:
|
|
195
|
+
self._owner.stop()
|
|
196
|
+
except Exception as e:
|
|
197
|
+
logger.warning(f"Error stopping process: {e}")
|
|
198
|
+
self._owner = None
|
|
199
|
+
|
|
200
|
+
if self._lock:
|
|
201
|
+
try:
|
|
202
|
+
self._lock.release()
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.warning(f"Error releasing lock: {e}")
|
|
205
|
+
self._lock = None
|
|
206
|
+
|
|
207
|
+
if self._client:
|
|
208
|
+
try:
|
|
209
|
+
self._client.close()
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
self._client = None
|
|
213
|
+
|
|
214
|
+
self._is_owner = False
|
|
215
|
+
|
|
216
|
+
def connect(self, wait_ready: bool = True, timeout: float | None = None) -> ServerClient:
|
|
217
|
+
"""
|
|
218
|
+
Connect to the server, starting it if necessary.
|
|
219
|
+
|
|
220
|
+
This is the main entry point. Multiple processes can call this concurrently:
|
|
221
|
+
- First process to acquire the lock becomes the owner and starts the server
|
|
222
|
+
- Other processes wait for the server to be ready and connect as clients
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
wait_ready: Whether to wait for the server to be ready.
|
|
226
|
+
timeout: Max seconds to wait for ready (uses startup_timeout if not specified).
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
ServerClient instance for making requests to the server.
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
ServerNotReady: If timeout is reached before server is ready.
|
|
233
|
+
"""
|
|
234
|
+
with self._state_lock:
|
|
235
|
+
if self._client is not None:
|
|
236
|
+
return self._client
|
|
237
|
+
|
|
238
|
+
# Create the lock
|
|
239
|
+
self._lock = SocketLock(
|
|
240
|
+
port=self._lock_port,
|
|
241
|
+
socket_path=self._lock_socket,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Try to become the owner
|
|
245
|
+
if self._lock.try_acquire():
|
|
246
|
+
logger.info(f"Acquired lock for {self.name}, becoming owner")
|
|
247
|
+
self._is_owner = True
|
|
248
|
+
|
|
249
|
+
# Start the server process
|
|
250
|
+
self._owner = ProcessOwner(
|
|
251
|
+
command=self.command,
|
|
252
|
+
env=self.env,
|
|
253
|
+
cwd=self.cwd,
|
|
254
|
+
stdout=self.stdout,
|
|
255
|
+
stderr=self.stderr,
|
|
256
|
+
health_check=self._create_health_check(),
|
|
257
|
+
health_check_interval=self.health_check_interval,
|
|
258
|
+
startup_timeout=self.startup_timeout,
|
|
259
|
+
restart_on_failure=self.restart_on_failure,
|
|
260
|
+
max_restarts=self.max_restarts,
|
|
261
|
+
restart_delay=self.restart_delay,
|
|
262
|
+
shutdown_timeout=self.shutdown_timeout,
|
|
263
|
+
shutdown_signal=self.shutdown_signal,
|
|
264
|
+
)
|
|
265
|
+
self._owner.start()
|
|
266
|
+
self._register_cleanup()
|
|
267
|
+
else:
|
|
268
|
+
logger.info(f"Lock already held for {self.name}, connecting as client")
|
|
269
|
+
# Release the lock object since we don't own it
|
|
270
|
+
self._lock = None
|
|
271
|
+
|
|
272
|
+
# Create client
|
|
273
|
+
self._client = ServerClient(
|
|
274
|
+
host="127.0.0.1",
|
|
275
|
+
port=self.port,
|
|
276
|
+
socket_path=self.socket_path,
|
|
277
|
+
health_check_url=self.health_check_url,
|
|
278
|
+
startup_timeout=timeout or self.startup_timeout,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Wait for ready outside the lock
|
|
282
|
+
if wait_ready:
|
|
283
|
+
self._client.wait_ready(timeout=timeout)
|
|
284
|
+
|
|
285
|
+
return self._client
|
|
286
|
+
|
|
287
|
+
def disconnect(self) -> None:
|
|
288
|
+
"""
|
|
289
|
+
Disconnect from the server.
|
|
290
|
+
|
|
291
|
+
If this process is the owner, stops the server process.
|
|
292
|
+
"""
|
|
293
|
+
self._cleanup()
|
|
294
|
+
|
|
295
|
+
def stop(self) -> None:
|
|
296
|
+
"""Alias for disconnect()."""
|
|
297
|
+
self.disconnect()
|
|
298
|
+
|
|
299
|
+
def get_client(self) -> ServerClient | None:
|
|
300
|
+
"""
|
|
301
|
+
Get the client without connecting.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
ServerClient if connected, None otherwise.
|
|
305
|
+
"""
|
|
306
|
+
return self._client
|
|
307
|
+
|
|
308
|
+
def __enter__(self) -> ServerClient:
|
|
309
|
+
"""Context manager entry - connects to the server."""
|
|
310
|
+
return self.connect()
|
|
311
|
+
|
|
312
|
+
def __exit__(
|
|
313
|
+
self,
|
|
314
|
+
exc_type: type[BaseException] | None,
|
|
315
|
+
exc_val: BaseException | None,
|
|
316
|
+
exc_tb: TracebackType | None,
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Context manager exit - disconnects from the server."""
|
|
319
|
+
self.disconnect()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class ManagedServer:
|
|
323
|
+
"""
|
|
324
|
+
Alternative interface for servers that don't need the singleton pattern.
|
|
325
|
+
|
|
326
|
+
Use this when you just want process management (health checks, restarts)
|
|
327
|
+
without the distributed coordination.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
def __init__(
|
|
331
|
+
self,
|
|
332
|
+
name: str,
|
|
333
|
+
command: list[str],
|
|
334
|
+
port: int | None = None,
|
|
335
|
+
socket: str | Path | None = None,
|
|
336
|
+
**kwargs: Any,
|
|
337
|
+
):
|
|
338
|
+
"""
|
|
339
|
+
Initialize the managed server.
|
|
340
|
+
|
|
341
|
+
Takes the same arguments as SingleServer.
|
|
342
|
+
"""
|
|
343
|
+
self.name = name
|
|
344
|
+
self._command_template = command
|
|
345
|
+
self.port = port
|
|
346
|
+
self.socket_path = Path(socket) if socket else None
|
|
347
|
+
self._kwargs = kwargs
|
|
348
|
+
self._owner: ProcessOwner | None = None
|
|
349
|
+
self._client: ServerClient | None = None
|
|
350
|
+
|
|
351
|
+
@property
|
|
352
|
+
def command(self) -> list[str]:
|
|
353
|
+
"""Build the command with placeholders replaced."""
|
|
354
|
+
result = []
|
|
355
|
+
for part in self._command_template:
|
|
356
|
+
if "{port}" in part:
|
|
357
|
+
part = part.replace("{port}", str(self.port))
|
|
358
|
+
if "{socket}" in part:
|
|
359
|
+
part = part.replace("{socket}", str(self.socket_path))
|
|
360
|
+
result.append(part)
|
|
361
|
+
return result
|
|
362
|
+
|
|
363
|
+
def start(self) -> ServerClient:
|
|
364
|
+
"""Start the server and return a client."""
|
|
365
|
+
if self._owner is not None:
|
|
366
|
+
raise RuntimeError("Server already started")
|
|
367
|
+
|
|
368
|
+
health_check_url = self._kwargs.get("health_check_url", "/")
|
|
369
|
+
|
|
370
|
+
def create_health_check() -> Callable[[], bool]:
|
|
371
|
+
def check() -> bool:
|
|
372
|
+
try:
|
|
373
|
+
client = ServerClient(
|
|
374
|
+
host="127.0.0.1",
|
|
375
|
+
port=self.port,
|
|
376
|
+
socket_path=self.socket_path,
|
|
377
|
+
health_check_url=health_check_url,
|
|
378
|
+
startup_timeout=2.0,
|
|
379
|
+
)
|
|
380
|
+
return bool(client.check_ready())
|
|
381
|
+
except Exception:
|
|
382
|
+
return False
|
|
383
|
+
|
|
384
|
+
return check
|
|
385
|
+
|
|
386
|
+
self._owner = ProcessOwner(
|
|
387
|
+
command=self.command,
|
|
388
|
+
env=self._kwargs.get("env"),
|
|
389
|
+
cwd=self._kwargs.get("cwd"),
|
|
390
|
+
stdout=self._kwargs.get("stdout"),
|
|
391
|
+
stderr=self._kwargs.get("stderr"),
|
|
392
|
+
health_check=create_health_check(),
|
|
393
|
+
health_check_interval=self._kwargs.get("health_check_interval", 5.0),
|
|
394
|
+
startup_timeout=self._kwargs.get("startup_timeout", 30.0),
|
|
395
|
+
restart_on_failure=self._kwargs.get("restart_on_failure", True),
|
|
396
|
+
max_restarts=self._kwargs.get("max_restarts", 3),
|
|
397
|
+
restart_delay=self._kwargs.get("restart_delay", 1.0),
|
|
398
|
+
shutdown_timeout=self._kwargs.get("shutdown_timeout", 10.0),
|
|
399
|
+
shutdown_signal=self._kwargs.get("shutdown_signal", signal.SIGTERM),
|
|
400
|
+
)
|
|
401
|
+
self._owner.start()
|
|
402
|
+
|
|
403
|
+
self._client = ServerClient(
|
|
404
|
+
host="127.0.0.1",
|
|
405
|
+
port=self.port,
|
|
406
|
+
socket_path=self.socket_path,
|
|
407
|
+
health_check_url=health_check_url,
|
|
408
|
+
startup_timeout=self._kwargs.get("startup_timeout", 30.0),
|
|
409
|
+
)
|
|
410
|
+
self._client.wait_ready()
|
|
411
|
+
|
|
412
|
+
return self._client
|
|
413
|
+
|
|
414
|
+
def stop(self) -> None:
|
|
415
|
+
"""Stop the server."""
|
|
416
|
+
if self._owner:
|
|
417
|
+
self._owner.stop()
|
|
418
|
+
self._owner = None
|
|
419
|
+
if self._client:
|
|
420
|
+
self._client.close()
|
|
421
|
+
self._client = None
|
|
422
|
+
|
|
423
|
+
def __enter__(self) -> ServerClient:
|
|
424
|
+
return self.start()
|
|
425
|
+
|
|
426
|
+
def __exit__(
|
|
427
|
+
self,
|
|
428
|
+
exc_type: type[BaseException] | None,
|
|
429
|
+
exc_val: BaseException | None,
|
|
430
|
+
exc_tb: TracebackType | None,
|
|
431
|
+
) -> None:
|
|
432
|
+
self.stop()
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: singleserver
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Manage singleton server processes across multiple workers using atomic socket binding
|
|
5
|
+
Project-URL: Homepage, https://github.com/Technology-Company/singleserver
|
|
6
|
+
Project-URL: Documentation, https://github.com/Technology-Company/singleserver#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/Technology-Company/singleserver
|
|
8
|
+
Project-URL: Issues, https://github.com/Technology-Company/singleserver/issues
|
|
9
|
+
Author-email: Johanna Mae Dimayuga <johanna@techco.fi>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: coordination,django,gunicorn,multiprocess,process,server,singleton
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: POSIX
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: requests>=2.25.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-timeout>=2.0.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: types-requests>=2.25.0; extra == 'dev'
|
|
34
|
+
Provides-Extra: unix-sockets
|
|
35
|
+
Requires-Dist: requests-unixsocket>=0.3.0; extra == 'unix-sockets'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# singleserver
|
|
39
|
+
|
|
40
|
+
Manage singleton server processes across multiple workers using atomic socket binding.
|
|
41
|
+
|
|
42
|
+
## Problem
|
|
43
|
+
|
|
44
|
+
When running web applications with multiple workers (e.g., gunicorn), you often need auxiliary services (API servers, background processors, caches) that should only run as a **single instance** shared by all workers.
|
|
45
|
+
|
|
46
|
+
Current solutions like systemd unit files or separate containers require additional infrastructure and make local development different from production.
|
|
47
|
+
|
|
48
|
+
## Solution
|
|
49
|
+
|
|
50
|
+
`singleserver` uses the OS-level guarantee that **only one process can bind a socket at a time**. When multiple workers call `connect()`:
|
|
51
|
+
|
|
52
|
+
1. First worker acquires the lock and starts the server
|
|
53
|
+
2. Other workers detect the lock is held and connect as clients
|
|
54
|
+
3. If the owner dies, another worker can take over
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
Worker 1: connect() → bind(:18765) → SUCCESS → starts server, returns client
|
|
58
|
+
Worker 2: connect() → bind(:18765) → EADDRINUSE → waits for ready, returns client
|
|
59
|
+
Worker 3: connect() → bind(:18765) → EADDRINUSE → waits for ready, returns client
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install singleserver
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick Start
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from singleserver import SingleServer
|
|
72
|
+
|
|
73
|
+
# Define the server
|
|
74
|
+
api_server = SingleServer(
|
|
75
|
+
name="api-server",
|
|
76
|
+
command=["python", "-m", "my_api", "--port", "{port}"],
|
|
77
|
+
port=8765,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Connect (starts if needed, connects if already running)
|
|
81
|
+
with api_server.connect() as client:
|
|
82
|
+
response = client.get("/data.json")
|
|
83
|
+
print(response.json())
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Features
|
|
87
|
+
|
|
88
|
+
- **Atomic coordination**: Uses socket binding for distributed locking
|
|
89
|
+
- **Health monitoring**: Configurable health checks with automatic restarts
|
|
90
|
+
- **Output handling**: Redirect stdout/stderr to files
|
|
91
|
+
- **Graceful shutdown**: SIGTERM with timeout, then SIGKILL
|
|
92
|
+
- **No external dependencies**: Pure Python, no Redis/etcd/etc needed
|
|
93
|
+
- **Same code for dev and prod**: No systemd unit files required
|
|
94
|
+
|
|
95
|
+
## Configuration
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
SingleServer(
|
|
99
|
+
name="my-service", # Identifier for logging
|
|
100
|
+
command=["python", "server.py", ...], # Command to run ({port} is replaced)
|
|
101
|
+
|
|
102
|
+
# Server address
|
|
103
|
+
port=8765, # TCP port
|
|
104
|
+
# OR
|
|
105
|
+
socket="/tmp/my-service.sock", # Unix socket (faster, more secure)
|
|
106
|
+
|
|
107
|
+
# Health checks
|
|
108
|
+
health_check_url="/", # URL to poll for readiness
|
|
109
|
+
health_check_interval=5.0, # Seconds between checks
|
|
110
|
+
startup_timeout=30.0, # Max seconds to wait for startup
|
|
111
|
+
|
|
112
|
+
# Restart behavior
|
|
113
|
+
restart_on_failure=True, # Auto-restart if process dies
|
|
114
|
+
max_restarts=3, # Give up after N restarts
|
|
115
|
+
restart_delay=1.0, # Seconds between restart attempts
|
|
116
|
+
|
|
117
|
+
# Process options
|
|
118
|
+
env={"DATABASE_URL": "..."}, # Environment variables
|
|
119
|
+
cwd="/app", # Working directory
|
|
120
|
+
stdout="/var/log/my-service.log", # Log file
|
|
121
|
+
stderr="stdout", # Redirect stderr to stdout
|
|
122
|
+
|
|
123
|
+
# Shutdown
|
|
124
|
+
shutdown_timeout=10.0, # Seconds for graceful stop
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Use Cases
|
|
129
|
+
|
|
130
|
+
### Internal API server
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from singleserver import SingleServer
|
|
134
|
+
|
|
135
|
+
api = SingleServer(
|
|
136
|
+
name="internal-api",
|
|
137
|
+
command=["python", "-m", "internal_api", "-p", "{port}"],
|
|
138
|
+
port=8765,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def my_view(request):
|
|
142
|
+
with api.connect() as client:
|
|
143
|
+
data = client.get("/query").json()
|
|
144
|
+
return JsonResponse(data)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Background service
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
service = SingleServer(
|
|
151
|
+
name="background-service",
|
|
152
|
+
command=["python", "service.py", "--port", "{port}"],
|
|
153
|
+
port=8888,
|
|
154
|
+
health_check_url="/health",
|
|
155
|
+
restart_on_failure=True,
|
|
156
|
+
)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## How It Works
|
|
160
|
+
|
|
161
|
+
1. **Lock acquisition**: Uses a separate socket (port + 10000 by default) for coordination
|
|
162
|
+
2. **Process management**: Owner spawns subprocess in new process group
|
|
163
|
+
3. **Health monitoring**: Background thread polls health endpoint
|
|
164
|
+
4. **Restart logic**: Configurable restart attempts with delay
|
|
165
|
+
5. **Cleanup**: atexit handler and signal handlers for clean shutdown
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
# Install dev dependencies
|
|
171
|
+
uv sync --all-extras
|
|
172
|
+
|
|
173
|
+
# Run tests
|
|
174
|
+
uv run pytest
|
|
175
|
+
|
|
176
|
+
# Type checking
|
|
177
|
+
uv run mypy singleserver
|
|
178
|
+
|
|
179
|
+
# Linting
|
|
180
|
+
uv run ruff check singleserver tests
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
singleserver/__init__.py,sha256=EgSpurqe2RhhGvvKghb74xU9lOsGQexoXP0mMmP4fu8,1052
|
|
2
|
+
singleserver/client.py,sha256=6OG9l10xUXG_wLoJOVocy8jRM4f4JItm0A6yr1Rtd0Q,9560
|
|
3
|
+
singleserver/lock.py,sha256=e0AWDnYk3I6_4dYSCwxz4H2FCkb9ItJLinU3palZ2ak,9100
|
|
4
|
+
singleserver/process.py,sha256=GqUyGWZXy85biwiShuU95tMOQXAmbiBBDeXrTEikq-o,17062
|
|
5
|
+
singleserver/server.py,sha256=otrSQVQVNXEpVosG4GuCJk_Iozuqnbwmkt77c0D9cMI,14978
|
|
6
|
+
singleserver-0.1.0.dist-info/METADATA,sha256=oZeiUEhP_NpZXtRuaHkO1v0Cml6DJUCn9hXmI6b5KeY,6013
|
|
7
|
+
singleserver-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
8
|
+
singleserver-0.1.0.dist-info/licenses/LICENSE,sha256=x6w9qzvmsY6V3iRykNoiSWD0NqCZeV6VejFT4kVi6Fg,1075
|
|
9
|
+
singleserver-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Technology Company
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|