labtasker-server 2.0.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.
- labtasker_server/__init__.py +3 -0
- labtasker_server/__main__.py +3 -0
- labtasker_server/app.py +416 -0
- labtasker_server/cli.py +371 -0
- labtasker_server/config.py +42 -0
- labtasker_server/database.py +176 -0
- labtasker_server/errors.py +27 -0
- labtasker_server/filtering.py +528 -0
- labtasker_server/local.py +453 -0
- labtasker_server/logging.py +47 -0
- labtasker_server/middleware.py +73 -0
- labtasker_server/migrations/__init__.py +1 -0
- labtasker_server/migrations/env.py +20 -0
- labtasker_server/migrations/versions/0001_initial.py +135 -0
- labtasker_server/migrations/versions/__init__.py +1 -0
- labtasker_server/models.py +139 -0
- labtasker_server/pagination.py +126 -0
- labtasker_server/py.typed +1 -0
- labtasker_server/schemas.py +265 -0
- labtasker_server/services/__init__.py +1 -0
- labtasker_server/services/queues.py +66 -0
- labtasker_server/services/tasks.py +859 -0
- labtasker_server/validation.py +150 -0
- labtasker_server-2.0.0.dist-info/METADATA +16 -0
- labtasker_server-2.0.0.dist-info/RECORD +28 -0
- labtasker_server-2.0.0.dist-info/WHEEL +4 -0
- labtasker_server-2.0.0.dist-info/entry_points.txt +2 -0
- labtasker_server-2.0.0.dist-info/licenses/LICENSE +201 -0
labtasker_server/cli.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import socket
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
import uvicorn
|
|
14
|
+
|
|
15
|
+
from labtasker_server import __version__
|
|
16
|
+
from labtasker_server.app import create_app
|
|
17
|
+
from labtasker_server.config import ServerSettings
|
|
18
|
+
from labtasker_server.database import DatabaseOwnershipError
|
|
19
|
+
from labtasker_server.local import (
|
|
20
|
+
database_identity,
|
|
21
|
+
database_is_free,
|
|
22
|
+
ensure_local_daemon,
|
|
23
|
+
ensure_runtime_directory,
|
|
24
|
+
has_runtime_artifacts,
|
|
25
|
+
local_paths,
|
|
26
|
+
make_metadata,
|
|
27
|
+
metadata_matches_database,
|
|
28
|
+
metadata_owner_is_verified,
|
|
29
|
+
read_metadata,
|
|
30
|
+
remove_generation_artifacts,
|
|
31
|
+
remove_generation_socket,
|
|
32
|
+
remove_stale_artifacts,
|
|
33
|
+
require_local_capabilities,
|
|
34
|
+
socket_health,
|
|
35
|
+
startup_age,
|
|
36
|
+
throttle_remaining,
|
|
37
|
+
write_metadata,
|
|
38
|
+
)
|
|
39
|
+
from labtasker_server.logging import uvicorn_log_config
|
|
40
|
+
|
|
41
|
+
app = typer.Typer(
|
|
42
|
+
help="Run the Labtasker v2 HTTP Server.",
|
|
43
|
+
add_completion=False,
|
|
44
|
+
no_args_is_help=True,
|
|
45
|
+
pretty_exceptions_enable=False,
|
|
46
|
+
rich_markup_mode=None,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.callback()
|
|
51
|
+
def main() -> None:
|
|
52
|
+
"""Run and manage the Labtasker v2 Server."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command()
|
|
56
|
+
def serve(
|
|
57
|
+
host: Annotated[
|
|
58
|
+
str,
|
|
59
|
+
typer.Option(help="Address to bind; a non-loopback address requires a token."),
|
|
60
|
+
] = "127.0.0.1",
|
|
61
|
+
port: Annotated[
|
|
62
|
+
int,
|
|
63
|
+
typer.Option(min=1, max=65535, help="TCP port to listen on."),
|
|
64
|
+
] = 8000,
|
|
65
|
+
database: Annotated[
|
|
66
|
+
Path,
|
|
67
|
+
typer.Option(help="SQLite database file owned by this Server process."),
|
|
68
|
+
] = Path(".labtasker/server.db"),
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Initialize the database and run one Labtasker v2 Server process.
|
|
71
|
+
|
|
72
|
+
Authentication is optional on loopback. For any non-loopback bind, set the
|
|
73
|
+
shared token with LABTASKER_SERVER_TOKEN; tokens are never accepted as a
|
|
74
|
+
command-line option. Run only one Server process for each SQLite file.
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
labtasker-server serve
|
|
80
|
+
LABTASKER_SERVER_TOKEN=secret labtasker-server serve \\
|
|
81
|
+
--host 0.0.0.0 --database /data/labtasker.db
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
settings = ServerSettings.from_values(host=host, port=port, database=database)
|
|
85
|
+
except ValueError as error:
|
|
86
|
+
typer.echo(f"[labtasker-server] Server configuration error: {error}", err=True)
|
|
87
|
+
raise typer.Exit(1) from error
|
|
88
|
+
try:
|
|
89
|
+
application = create_app(settings)
|
|
90
|
+
except (DatabaseOwnershipError, OSError, RuntimeError) as error:
|
|
91
|
+
typer.echo(f"[labtasker-server] Server startup error: {error}", err=True)
|
|
92
|
+
raise typer.Exit(1) from error
|
|
93
|
+
uvicorn.run(
|
|
94
|
+
application,
|
|
95
|
+
host=settings.host,
|
|
96
|
+
port=settings.port,
|
|
97
|
+
log_level="info",
|
|
98
|
+
log_config=uvicorn_log_config(),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command()
|
|
103
|
+
def start() -> None:
|
|
104
|
+
"""Start the current directory's local daemon, or report the existing one."""
|
|
105
|
+
try:
|
|
106
|
+
paths = local_paths()
|
|
107
|
+
started, metadata = ensure_local_daemon(
|
|
108
|
+
paths.directory,
|
|
109
|
+
bypass_throttle=True,
|
|
110
|
+
server_version=__version__,
|
|
111
|
+
emit=lambda message: typer.echo(f"[labtasker-server] {message}", err=True),
|
|
112
|
+
)
|
|
113
|
+
except (OSError, RuntimeError) as error:
|
|
114
|
+
typer.echo(f"[labtasker-server] Local Server startup error: {error}", err=True)
|
|
115
|
+
raise typer.Exit(1) from error
|
|
116
|
+
action = "started" if started else "already running"
|
|
117
|
+
pid = metadata.pid if metadata is not None else "unknown"
|
|
118
|
+
typer.echo(
|
|
119
|
+
f"[labtasker-server] {action} local daemon pid={pid} socket={paths.socket}",
|
|
120
|
+
err=True,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command("_ensure-daemon", hidden=True)
|
|
125
|
+
def ensure_daemon(
|
|
126
|
+
directory: Annotated[Path, typer.Option(hidden=True)],
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Ensure one healthy local daemon for an automatic Client request."""
|
|
129
|
+
try:
|
|
130
|
+
paths = local_paths(directory)
|
|
131
|
+
started, metadata = ensure_local_daemon(
|
|
132
|
+
paths.directory,
|
|
133
|
+
bypass_throttle=False,
|
|
134
|
+
server_version=__version__,
|
|
135
|
+
emit=lambda message: typer.echo(f"[labtasker-server] {message}", err=True),
|
|
136
|
+
)
|
|
137
|
+
except (OSError, RuntimeError) as error:
|
|
138
|
+
typer.echo(f"[labtasker-server] automatic local startup error: {error}", err=True)
|
|
139
|
+
try:
|
|
140
|
+
result = _local_status(directory)
|
|
141
|
+
except (OSError, RuntimeError):
|
|
142
|
+
result = {
|
|
143
|
+
"state": "unhealthy",
|
|
144
|
+
"directory": str(directory.resolve()),
|
|
145
|
+
"database": None,
|
|
146
|
+
"socket": None,
|
|
147
|
+
"log": None,
|
|
148
|
+
"pid": None,
|
|
149
|
+
"version": None,
|
|
150
|
+
"retry_after_seconds": None,
|
|
151
|
+
}
|
|
152
|
+
result.update({"ok": False, "message": str(error)})
|
|
153
|
+
typer.echo(json.dumps(result, ensure_ascii=False))
|
|
154
|
+
raise typer.Exit(1) from error
|
|
155
|
+
result = _local_status(paths.directory)
|
|
156
|
+
result.update(
|
|
157
|
+
{
|
|
158
|
+
"ok": True,
|
|
159
|
+
"started": started,
|
|
160
|
+
"pid": metadata.pid if metadata is not None else result["pid"],
|
|
161
|
+
"version": (metadata.server_version if metadata is not None else result["version"]),
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
typer.echo(json.dumps(result, ensure_ascii=False))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@app.command()
|
|
168
|
+
def status() -> None:
|
|
169
|
+
"""Print the current directory's local daemon status as JSON."""
|
|
170
|
+
try:
|
|
171
|
+
paths = local_paths()
|
|
172
|
+
result = _local_status(paths.directory)
|
|
173
|
+
except (OSError, RuntimeError) as error:
|
|
174
|
+
typer.echo(f"[labtasker-server] Local Server status error: {error}", err=True)
|
|
175
|
+
raise typer.Exit(1) from error
|
|
176
|
+
typer.echo(json.dumps(result, indent=2, ensure_ascii=False) + "\n", nl=False)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@app.command()
|
|
180
|
+
def stop(
|
|
181
|
+
force: Annotated[
|
|
182
|
+
bool,
|
|
183
|
+
typer.Option(help="Send SIGKILL after the 30-second graceful deadline."),
|
|
184
|
+
] = False,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Stop the current directory's verified local daemon."""
|
|
187
|
+
try:
|
|
188
|
+
paths = local_paths()
|
|
189
|
+
except RuntimeError as error:
|
|
190
|
+
typer.echo(f"[labtasker-server] Local Server stop error: {error}", err=True)
|
|
191
|
+
raise typer.Exit(1) from error
|
|
192
|
+
metadata = read_metadata(paths)
|
|
193
|
+
if database_is_free(paths):
|
|
194
|
+
if has_runtime_artifacts(paths):
|
|
195
|
+
try:
|
|
196
|
+
remove_stale_artifacts(paths)
|
|
197
|
+
except RuntimeError as error:
|
|
198
|
+
typer.echo(f"[labtasker-server] Local Server stop error: {error}", err=True)
|
|
199
|
+
raise typer.Exit(1) from error
|
|
200
|
+
typer.echo("[labtasker-server] local daemon is already stopped", err=True)
|
|
201
|
+
return
|
|
202
|
+
if (
|
|
203
|
+
metadata is None
|
|
204
|
+
or metadata.role != "daemon"
|
|
205
|
+
or not metadata_owner_is_verified(paths, metadata)
|
|
206
|
+
):
|
|
207
|
+
typer.echo(
|
|
208
|
+
"[labtasker-server] Local Server stop error: "
|
|
209
|
+
"database owner is not a verified local daemon.",
|
|
210
|
+
err=True,
|
|
211
|
+
)
|
|
212
|
+
raise typer.Exit(1)
|
|
213
|
+
|
|
214
|
+
with suppress(ProcessLookupError):
|
|
215
|
+
os.kill(metadata.pid, signal.SIGTERM)
|
|
216
|
+
typer.echo(f"[labtasker-server] stopping local daemon pid={metadata.pid}", err=True)
|
|
217
|
+
if _wait_for_exit(paths.directory, timeout=30.0):
|
|
218
|
+
remove_generation_artifacts(paths, metadata.generation)
|
|
219
|
+
typer.echo(f"[labtasker-server] stopped local daemon pid={metadata.pid}", err=True)
|
|
220
|
+
return
|
|
221
|
+
if not force:
|
|
222
|
+
typer.echo(
|
|
223
|
+
"[labtasker-server] Local Server stop error: daemon did not stop within "
|
|
224
|
+
"30 seconds; retry with --force.",
|
|
225
|
+
err=True,
|
|
226
|
+
)
|
|
227
|
+
raise typer.Exit(1)
|
|
228
|
+
|
|
229
|
+
current = read_metadata(paths)
|
|
230
|
+
if (
|
|
231
|
+
current is None
|
|
232
|
+
or current.generation != metadata.generation
|
|
233
|
+
or not metadata_owner_is_verified(paths, current)
|
|
234
|
+
):
|
|
235
|
+
typer.echo(
|
|
236
|
+
"[labtasker-server] Local Server stop error: "
|
|
237
|
+
"daemon identity changed; refusing SIGKILL.",
|
|
238
|
+
err=True,
|
|
239
|
+
)
|
|
240
|
+
raise typer.Exit(1)
|
|
241
|
+
os.kill(current.pid, signal.SIGKILL)
|
|
242
|
+
typer.echo(f"[labtasker-server] force-stopping local daemon pid={current.pid}", err=True)
|
|
243
|
+
if not _wait_for_exit(paths.directory, timeout=5.0):
|
|
244
|
+
typer.echo(
|
|
245
|
+
"[labtasker-server] Local Server stop error: database ownership was not released.",
|
|
246
|
+
err=True,
|
|
247
|
+
)
|
|
248
|
+
raise typer.Exit(1)
|
|
249
|
+
remove_generation_artifacts(paths, current.generation)
|
|
250
|
+
typer.echo(f"[labtasker-server] stopped local daemon pid={current.pid}", err=True)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@app.command()
|
|
254
|
+
def logs() -> None:
|
|
255
|
+
"""Print the current directory's complete local Server log."""
|
|
256
|
+
try:
|
|
257
|
+
path = local_paths().log
|
|
258
|
+
typer.echo(path.read_text(encoding="utf-8"), nl=False)
|
|
259
|
+
except FileNotFoundError:
|
|
260
|
+
return
|
|
261
|
+
except (OSError, UnicodeError) as error:
|
|
262
|
+
typer.echo(f"[labtasker-server] Local Server log error: {error}", err=True)
|
|
263
|
+
raise typer.Exit(1) from error
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@app.command("_daemon", hidden=True)
|
|
267
|
+
def daemon(
|
|
268
|
+
directory: Annotated[Path, typer.Option(hidden=True)],
|
|
269
|
+
database_fd: Annotated[int, typer.Option(hidden=True)],
|
|
270
|
+
generation: Annotated[str, typer.Option(hidden=True)],
|
|
271
|
+
automatic_attempt_at: Annotated[float, typer.Option(hidden=True)],
|
|
272
|
+
) -> None:
|
|
273
|
+
"""Run one private local daemon process."""
|
|
274
|
+
paths = local_paths(directory)
|
|
275
|
+
listener: socket.socket | None = None
|
|
276
|
+
try:
|
|
277
|
+
require_local_capabilities()
|
|
278
|
+
ensure_runtime_directory(paths)
|
|
279
|
+
metadata = make_metadata(
|
|
280
|
+
paths,
|
|
281
|
+
generation=generation,
|
|
282
|
+
role="daemon",
|
|
283
|
+
pid=os.getpid(),
|
|
284
|
+
automatic_attempt_at=automatic_attempt_at,
|
|
285
|
+
database_fd=database_fd,
|
|
286
|
+
server_version=__version__,
|
|
287
|
+
)
|
|
288
|
+
write_metadata(paths, metadata)
|
|
289
|
+
if database_identity(database_fd) != (
|
|
290
|
+
metadata.database_device,
|
|
291
|
+
metadata.database_inode,
|
|
292
|
+
):
|
|
293
|
+
raise RuntimeError("Inherited database identity changed.")
|
|
294
|
+
settings = ServerSettings(database=paths.database, token=None, database_fd=database_fd)
|
|
295
|
+
application = create_app(settings)
|
|
296
|
+
os.close(database_fd)
|
|
297
|
+
database_fd = -1
|
|
298
|
+
|
|
299
|
+
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
300
|
+
listener.bind(str(paths.socket))
|
|
301
|
+
os.chmod(paths.socket, 0o600)
|
|
302
|
+
uvicorn.run(
|
|
303
|
+
application,
|
|
304
|
+
fd=listener.fileno(),
|
|
305
|
+
log_level="info",
|
|
306
|
+
log_config=uvicorn_log_config(),
|
|
307
|
+
)
|
|
308
|
+
except BaseException as error:
|
|
309
|
+
typer.echo(f"[labtasker-server] Local daemon failed: {error}", err=True)
|
|
310
|
+
raise
|
|
311
|
+
finally:
|
|
312
|
+
if database_fd >= 0:
|
|
313
|
+
os.close(database_fd)
|
|
314
|
+
if listener is not None:
|
|
315
|
+
listener.close()
|
|
316
|
+
# Preserve the attempt metadata so an unexpected exit remains throttled.
|
|
317
|
+
# An explicit successful stop removes the full generation itself.
|
|
318
|
+
remove_generation_socket(paths, generation)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _local_status(directory: Path) -> dict[str, object]:
|
|
322
|
+
paths = local_paths(directory)
|
|
323
|
+
metadata = read_metadata(paths)
|
|
324
|
+
if socket_health(paths):
|
|
325
|
+
state = "running"
|
|
326
|
+
retry_after: float | None = None
|
|
327
|
+
elif not database_is_free(paths):
|
|
328
|
+
state = (
|
|
329
|
+
"starting"
|
|
330
|
+
if metadata is not None
|
|
331
|
+
and metadata_owner_is_verified(paths, metadata)
|
|
332
|
+
and startup_age(metadata) is not None
|
|
333
|
+
else "unhealthy"
|
|
334
|
+
)
|
|
335
|
+
retry_after = None
|
|
336
|
+
else:
|
|
337
|
+
remaining = (
|
|
338
|
+
throttle_remaining(metadata)
|
|
339
|
+
if metadata is not None and metadata_matches_database(paths, metadata)
|
|
340
|
+
else 0.0
|
|
341
|
+
)
|
|
342
|
+
if remaining > 0:
|
|
343
|
+
state = "backoff"
|
|
344
|
+
retry_after = round(remaining, 3)
|
|
345
|
+
elif has_runtime_artifacts(paths):
|
|
346
|
+
state = "stale"
|
|
347
|
+
retry_after = None
|
|
348
|
+
else:
|
|
349
|
+
state = "stopped"
|
|
350
|
+
retry_after = None
|
|
351
|
+
verified = metadata is not None and metadata_owner_is_verified(paths, metadata)
|
|
352
|
+
return {
|
|
353
|
+
"state": state,
|
|
354
|
+
"directory": str(paths.directory),
|
|
355
|
+
"database": str(paths.database),
|
|
356
|
+
"socket": str(paths.socket),
|
|
357
|
+
"log": str(paths.log),
|
|
358
|
+
"pid": metadata.pid if verified and metadata is not None else None,
|
|
359
|
+
"version": metadata.server_version if verified and metadata is not None else None,
|
|
360
|
+
"retry_after_seconds": retry_after,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _wait_for_exit(directory: Path, *, timeout: float) -> bool:
|
|
365
|
+
paths = local_paths(directory)
|
|
366
|
+
deadline = time.monotonic() + timeout
|
|
367
|
+
while time.monotonic() < deadline:
|
|
368
|
+
if database_is_free(paths):
|
|
369
|
+
return True
|
|
370
|
+
time.sleep(0.05)
|
|
371
|
+
return database_is_free(paths)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class ServerSettings:
|
|
11
|
+
host: str = "127.0.0.1"
|
|
12
|
+
port: int = 8000
|
|
13
|
+
database: Path = Path(".labtasker/server.db")
|
|
14
|
+
token: str | None = None
|
|
15
|
+
database_fd: int | None = None
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_values(
|
|
19
|
+
cls,
|
|
20
|
+
*,
|
|
21
|
+
host: str = "127.0.0.1",
|
|
22
|
+
port: int = 8000,
|
|
23
|
+
database: str | Path = ".labtasker/server.db",
|
|
24
|
+
token: str | None = None,
|
|
25
|
+
) -> ServerSettings:
|
|
26
|
+
effective_token = os.environ.get("LABTASKER_SERVER_TOKEN") if token is None else token
|
|
27
|
+
if effective_token == "":
|
|
28
|
+
raise ValueError("LABTASKER_SERVER_TOKEN must not be empty.")
|
|
29
|
+
if not 1 <= port <= 65535:
|
|
30
|
+
raise ValueError("port must be between 1 and 65535.")
|
|
31
|
+
if effective_token is None and not _is_tokenless_host_allowed(host):
|
|
32
|
+
raise ValueError("A token is required when binding to a non-loopback host.")
|
|
33
|
+
return cls(host=host, port=port, database=Path(database), token=effective_token)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_tokenless_host_allowed(host: str) -> bool:
|
|
37
|
+
if host.lower() == "localhost":
|
|
38
|
+
return True
|
|
39
|
+
try:
|
|
40
|
+
return ipaddress.ip_address(host).is_loopback
|
|
41
|
+
except ValueError:
|
|
42
|
+
return False
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import fcntl
|
|
10
|
+
except ImportError: # pragma: no cover - explicit HTTP remains best effort off POSIX
|
|
11
|
+
fcntl = None # type: ignore[assignment]
|
|
12
|
+
|
|
13
|
+
from alembic import command
|
|
14
|
+
from alembic.config import Config
|
|
15
|
+
from sqlalchemy import Engine, create_engine, event, inspect, text
|
|
16
|
+
from sqlalchemy.exc import OperationalError
|
|
17
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
18
|
+
|
|
19
|
+
from labtasker_server.errors import DomainError
|
|
20
|
+
from labtasker_server.models import QueueRow
|
|
21
|
+
|
|
22
|
+
LOCAL_GITIGNORE = "*\n!.gitignore\n"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DatabaseOwnershipError(RuntimeError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Database:
|
|
30
|
+
def __init__(self, path: Path, *, ownership_fd: int | None = None) -> None:
|
|
31
|
+
self.path = path.resolve()
|
|
32
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
labtasker_dir = next(
|
|
34
|
+
(parent for parent in self.path.parents if parent.name == ".labtasker"),
|
|
35
|
+
None,
|
|
36
|
+
)
|
|
37
|
+
if labtasker_dir is not None:
|
|
38
|
+
_ensure_local_gitignore(labtasker_dir)
|
|
39
|
+
self._ownership_fd: int | None = _acquire_database_ownership(self.path, ownership_fd)
|
|
40
|
+
self.engine = _create_sqlite_engine(self.path)
|
|
41
|
+
self._session_factory = sessionmaker(self.engine, expire_on_commit=False)
|
|
42
|
+
|
|
43
|
+
def initialize(self) -> None:
|
|
44
|
+
existing_tables = set(inspect(self.engine).get_table_names())
|
|
45
|
+
is_fresh = not existing_tables
|
|
46
|
+
if existing_tables and "alembic_version" not in existing_tables:
|
|
47
|
+
raise RuntimeError("Database has tables but is not a recognized Labtasker v2 schema.")
|
|
48
|
+
|
|
49
|
+
alembic_config = Config()
|
|
50
|
+
alembic_config.set_main_option(
|
|
51
|
+
"script_location",
|
|
52
|
+
str(Path(__file__).resolve().parent / "migrations"),
|
|
53
|
+
)
|
|
54
|
+
with self.engine.begin() as connection:
|
|
55
|
+
alembic_config.attributes["connection"] = connection
|
|
56
|
+
command.upgrade(alembic_config, "head")
|
|
57
|
+
_verify_sqlite_settings(self.engine)
|
|
58
|
+
|
|
59
|
+
if is_fresh:
|
|
60
|
+
with self.write_session() as session:
|
|
61
|
+
session.add(QueueRow(name="default"))
|
|
62
|
+
|
|
63
|
+
@contextmanager
|
|
64
|
+
def read_session(self) -> Iterator[Session]:
|
|
65
|
+
with self._session_factory() as session:
|
|
66
|
+
yield session
|
|
67
|
+
|
|
68
|
+
@contextmanager
|
|
69
|
+
def write_session(self) -> Iterator[Session]:
|
|
70
|
+
with self._session_factory() as session:
|
|
71
|
+
try:
|
|
72
|
+
session.execute(text("BEGIN IMMEDIATE"))
|
|
73
|
+
yield session
|
|
74
|
+
session.commit()
|
|
75
|
+
except OperationalError as error:
|
|
76
|
+
session.rollback()
|
|
77
|
+
if _is_sqlite_busy(error):
|
|
78
|
+
raise DomainError(
|
|
79
|
+
503,
|
|
80
|
+
"database_busy",
|
|
81
|
+
"The database is busy; retry the operation.",
|
|
82
|
+
{},
|
|
83
|
+
) from error
|
|
84
|
+
raise
|
|
85
|
+
except BaseException:
|
|
86
|
+
session.rollback()
|
|
87
|
+
raise
|
|
88
|
+
|
|
89
|
+
def dispose(self) -> None:
|
|
90
|
+
self.engine.dispose()
|
|
91
|
+
if self._ownership_fd is not None:
|
|
92
|
+
os.close(self._ownership_fd)
|
|
93
|
+
self._ownership_fd = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _acquire_database_ownership(path: Path, inherited_fd: int | None) -> int:
|
|
97
|
+
if inherited_fd is None:
|
|
98
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
99
|
+
if fcntl is not None:
|
|
100
|
+
try:
|
|
101
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
102
|
+
except BlockingIOError as error:
|
|
103
|
+
os.close(fd)
|
|
104
|
+
raise DatabaseOwnershipError(
|
|
105
|
+
f"Another Server process already owns database {path}."
|
|
106
|
+
) from error
|
|
107
|
+
else:
|
|
108
|
+
fd = os.dup(inherited_fd)
|
|
109
|
+
os.set_inheritable(fd, False)
|
|
110
|
+
|
|
111
|
+
descriptor_stat = os.fstat(fd)
|
|
112
|
+
try:
|
|
113
|
+
path_stat = path.stat()
|
|
114
|
+
except OSError:
|
|
115
|
+
os.close(fd)
|
|
116
|
+
raise
|
|
117
|
+
if (descriptor_stat.st_dev, descriptor_stat.st_ino) != (
|
|
118
|
+
path_stat.st_dev,
|
|
119
|
+
path_stat.st_ino,
|
|
120
|
+
):
|
|
121
|
+
os.close(fd)
|
|
122
|
+
raise DatabaseOwnershipError(
|
|
123
|
+
f"Database descriptor does not identify configured path {path}."
|
|
124
|
+
)
|
|
125
|
+
return fd
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _create_sqlite_engine(path: Path) -> Engine:
|
|
129
|
+
engine = create_engine(
|
|
130
|
+
f"sqlite+pysqlite:///{path}",
|
|
131
|
+
connect_args={"check_same_thread": False, "timeout": 5.0},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
@event.listens_for(engine, "connect")
|
|
135
|
+
def configure_connection(dbapi_connection: object, _: object) -> None:
|
|
136
|
+
cursor = dbapi_connection.cursor() # type: ignore[attr-defined]
|
|
137
|
+
try:
|
|
138
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
139
|
+
cursor.execute("PRAGMA busy_timeout=5000")
|
|
140
|
+
cursor.execute("PRAGMA synchronous=FULL")
|
|
141
|
+
cursor.execute("PRAGMA journal_mode=WAL")
|
|
142
|
+
finally:
|
|
143
|
+
cursor.close()
|
|
144
|
+
|
|
145
|
+
return engine
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _ensure_local_gitignore(labtasker_dir: Path) -> None:
|
|
149
|
+
try:
|
|
150
|
+
with (labtasker_dir / ".gitignore").open("x", encoding="utf-8", newline="\n") as stream:
|
|
151
|
+
stream.write(LOCAL_GITIGNORE)
|
|
152
|
+
except FileExistsError:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _is_sqlite_busy(error: OperationalError) -> bool:
|
|
157
|
+
code = getattr(error.orig, "sqlite_errorcode", None)
|
|
158
|
+
return code in {5, 6} or "database is locked" in str(error.orig).lower()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _verify_sqlite_settings(engine: Engine) -> None:
|
|
162
|
+
with engine.connect() as connection:
|
|
163
|
+
actual = {
|
|
164
|
+
"journal_mode": connection.scalar(text("PRAGMA journal_mode")),
|
|
165
|
+
"foreign_keys": connection.scalar(text("PRAGMA foreign_keys")),
|
|
166
|
+
"busy_timeout": connection.scalar(text("PRAGMA busy_timeout")),
|
|
167
|
+
"synchronous": connection.scalar(text("PRAGMA synchronous")),
|
|
168
|
+
}
|
|
169
|
+
expected = {
|
|
170
|
+
"journal_mode": "wal",
|
|
171
|
+
"foreign_keys": 1,
|
|
172
|
+
"busy_timeout": 5000,
|
|
173
|
+
"synchronous": 2,
|
|
174
|
+
}
|
|
175
|
+
if actual != expected:
|
|
176
|
+
raise RuntimeError(f"Required SQLite settings were not applied: {actual!r}")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class DomainError(Exception):
|
|
9
|
+
status_code: int
|
|
10
|
+
code: str
|
|
11
|
+
message: str
|
|
12
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
13
|
+
|
|
14
|
+
def __str__(self) -> str:
|
|
15
|
+
return self.message
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def not_found(code: str, message: str, **details: Any) -> DomainError:
|
|
19
|
+
return DomainError(404, code, message, details)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def conflict(code: str, message: str, **details: Any) -> DomainError:
|
|
23
|
+
return DomainError(409, code, message, details)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def invalid(code: str, message: str, **details: Any) -> DomainError:
|
|
27
|
+
return DomainError(422, code, message, details)
|