seamless-database 2.0.1__tar.gz → 2.1.0__tar.gz
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.
- {seamless_database-2.0.1 → seamless_database-2.1.0}/PKG-INFO +22 -2
- {seamless_database-2.0.1 → seamless_database-2.1.0}/README.md +21 -0
- {seamless_database-2.0.1 → seamless_database-2.1.0}/database.py +294 -60
- {seamless_database-2.0.1 → seamless_database-2.1.0}/database_models.py +60 -0
- {seamless_database-2.0.1 → seamless_database-2.1.0}/pyproject.toml +1 -2
- {seamless_database-2.0.1 → seamless_database-2.1.0}/seamless_database.egg-info/PKG-INFO +22 -2
- {seamless_database-2.0.1 → seamless_database-2.1.0}/seamless_database.egg-info/SOURCES.txt +3 -1
- seamless_database-2.1.0/tests/test_execution_records.py +313 -0
- seamless_database-2.1.0/tests/test_random_port.py +82 -0
- {seamless_database-2.0.1 → seamless_database-2.1.0}/seamless_database.egg-info/dependency_links.txt +0 -0
- {seamless_database-2.0.1 → seamless_database-2.1.0}/seamless_database.egg-info/entry_points.txt +0 -0
- {seamless_database-2.0.1 → seamless_database-2.1.0}/seamless_database.egg-info/requires.txt +0 -0
- {seamless_database-2.0.1 → seamless_database-2.1.0}/seamless_database.egg-info/top_level.txt +0 -0
- {seamless_database-2.0.1 → seamless_database-2.1.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: seamless-database
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: SQLite-backed metadata database service for Seamless
|
|
5
5
|
Author: Sjoerd de Vries
|
|
6
6
|
License-Expression: MIT
|
|
@@ -13,7 +13,6 @@ Classifier: Intended Audience :: Science/Research
|
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: Programming Language :: Python :: 3
|
|
15
15
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
17
16
|
Classifier: Operating System :: OS Independent
|
|
18
17
|
Classifier: Topic :: Scientific/Engineering
|
|
19
18
|
Classifier: Topic :: Software Development :: Libraries
|
|
@@ -70,6 +69,27 @@ seamless-database seamless.db --port-range 5520 5530 --writable
|
|
|
70
69
|
seamless-database seamless.db --port 5522
|
|
71
70
|
```
|
|
72
71
|
|
|
72
|
+
If `--port` and `--port-range` are both omitted, `seamless-database` picks a random free port in the dynamic/private range (`49152-65535`).
|
|
73
|
+
|
|
74
|
+
### Status-file protocol
|
|
75
|
+
|
|
76
|
+
`seamless-database` does not require a status file. If `--status-file` is omitted, it runs independently.
|
|
77
|
+
|
|
78
|
+
If `--status-file` is provided, the file is used for two things:
|
|
79
|
+
|
|
80
|
+
1. Report the chosen port, especially when `--port-range` is used.
|
|
81
|
+
2. Report whether startup succeeded (`"running"`) or failed (`"failed"`).
|
|
82
|
+
|
|
83
|
+
The status-file protocol is simple:
|
|
84
|
+
|
|
85
|
+
1. Wait for the status file to exist and parse it as JSON.
|
|
86
|
+
2. Reuse the existing JSON object as the base payload. An empty JSON object `{}` is sufficient.
|
|
87
|
+
3. Choose or validate its listening port.
|
|
88
|
+
4. Once the HTTP server is up, rewrite the same file with `"status": "running"` and the selected `"port"`.
|
|
89
|
+
5. If startup fails before the server is running, rewrite the file with `"status": "failed"` instead.
|
|
90
|
+
|
|
91
|
+
If `remote-http-launcher` is used, it may pre-populate the JSON with fields such as the PID, workdir, or `"status": "starting"`. `seamless-database` preserves such fields when it writes back the final status.
|
|
92
|
+
|
|
73
93
|
### CLI options
|
|
74
94
|
|
|
75
95
|
| Option | Description |
|
|
@@ -46,6 +46,27 @@ seamless-database seamless.db --port-range 5520 5530 --writable
|
|
|
46
46
|
seamless-database seamless.db --port 5522
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
If `--port` and `--port-range` are both omitted, `seamless-database` picks a random free port in the dynamic/private range (`49152-65535`).
|
|
50
|
+
|
|
51
|
+
### Status-file protocol
|
|
52
|
+
|
|
53
|
+
`seamless-database` does not require a status file. If `--status-file` is omitted, it runs independently.
|
|
54
|
+
|
|
55
|
+
If `--status-file` is provided, the file is used for two things:
|
|
56
|
+
|
|
57
|
+
1. Report the chosen port, especially when `--port-range` is used.
|
|
58
|
+
2. Report whether startup succeeded (`"running"`) or failed (`"failed"`).
|
|
59
|
+
|
|
60
|
+
The status-file protocol is simple:
|
|
61
|
+
|
|
62
|
+
1. Wait for the status file to exist and parse it as JSON.
|
|
63
|
+
2. Reuse the existing JSON object as the base payload. An empty JSON object `{}` is sufficient.
|
|
64
|
+
3. Choose or validate its listening port.
|
|
65
|
+
4. Once the HTTP server is up, rewrite the same file with `"status": "running"` and the selected `"port"`.
|
|
66
|
+
5. If startup fails before the server is running, rewrite the file with `"status": "failed"` instead.
|
|
67
|
+
|
|
68
|
+
If `remote-http-launcher` is used, it may pre-populate the JSON with fields such as the PID, workdir, or `"status": "starting"`. `seamless-database` preserves such fields when it writes back the final status.
|
|
69
|
+
|
|
49
70
|
### CLI options
|
|
50
71
|
|
|
51
72
|
| Option | Description |
|
|
@@ -20,9 +20,13 @@ from database_models import (
|
|
|
20
20
|
SyntacticToSemantic,
|
|
21
21
|
Expression,
|
|
22
22
|
MetaData,
|
|
23
|
+
BucketProbe,
|
|
23
24
|
IrreproducibleTransformation,
|
|
24
25
|
)
|
|
25
26
|
|
|
27
|
+
DEFAULT_RANDOM_PORT_START = 49152
|
|
28
|
+
DEFAULT_RANDOM_PORT_END = 65535
|
|
29
|
+
|
|
26
30
|
|
|
27
31
|
STATUS_FILE_WAIT_TIMEOUT = 20.0
|
|
28
32
|
INACTIVITY_CHECK_INTERVAL = 1.0
|
|
@@ -135,6 +139,105 @@ class DatabaseError(Exception):
|
|
|
135
139
|
pass
|
|
136
140
|
|
|
137
141
|
|
|
142
|
+
def _conflict_response(message: str) -> web.Response:
|
|
143
|
+
return web.Response(status=409, text="ERROR: " + message)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _normalize_metadata_value(value):
|
|
147
|
+
if isinstance(value, dict):
|
|
148
|
+
return value
|
|
149
|
+
if isinstance(value, str):
|
|
150
|
+
value = json.loads(value)
|
|
151
|
+
if isinstance(value, dict):
|
|
152
|
+
return value
|
|
153
|
+
raise TypeError(type(value))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _normalize_freshness_tokens(value):
|
|
157
|
+
if isinstance(value, dict):
|
|
158
|
+
return value
|
|
159
|
+
if isinstance(value, str):
|
|
160
|
+
value = json.loads(value)
|
|
161
|
+
if isinstance(value, dict):
|
|
162
|
+
return value
|
|
163
|
+
raise TypeError(type(value))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _validate_execution_record_identity(
|
|
167
|
+
checksum: str, result: str, value: dict
|
|
168
|
+
) -> None:
|
|
169
|
+
schema_version = value.get("schema_version")
|
|
170
|
+
if not isinstance(schema_version, int):
|
|
171
|
+
raise ValueError("schema_version")
|
|
172
|
+
if value.get("tf_checksum") != checksum:
|
|
173
|
+
raise ValueError("tf_checksum")
|
|
174
|
+
if value.get("result_checksum") != result:
|
|
175
|
+
raise ValueError("result_checksum")
|
|
176
|
+
checksum_fields = value.get("checksum_fields")
|
|
177
|
+
if checksum_fields is not None:
|
|
178
|
+
if not isinstance(checksum_fields, list):
|
|
179
|
+
raise ValueError("checksum_fields")
|
|
180
|
+
for field in checksum_fields:
|
|
181
|
+
if not isinstance(field, str) or not field:
|
|
182
|
+
raise ValueError("checksum_fields")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _validate_bucket_probe_request(
|
|
186
|
+
bucket_kind: str,
|
|
187
|
+
label: str,
|
|
188
|
+
bucket_checksum: str,
|
|
189
|
+
freshness_tokens: dict,
|
|
190
|
+
captured_at: str,
|
|
191
|
+
) -> None:
|
|
192
|
+
if bucket_kind not in BUCKET_KINDS:
|
|
193
|
+
raise ValueError("bucket_kind")
|
|
194
|
+
if not isinstance(label, str) or not label:
|
|
195
|
+
raise ValueError("label")
|
|
196
|
+
parse_checksum(bucket_checksum, as_bytes=False)
|
|
197
|
+
if not isinstance(freshness_tokens, dict):
|
|
198
|
+
raise ValueError("freshness_tokens")
|
|
199
|
+
if not isinstance(captured_at, str) or not captured_at:
|
|
200
|
+
raise ValueError("captured_at")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _get_transformation_row(checksum: str):
|
|
204
|
+
try:
|
|
205
|
+
return Transformation[checksum]
|
|
206
|
+
except DoesNotExist:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _get_metadata_row(checksum: str):
|
|
211
|
+
try:
|
|
212
|
+
return MetaData[checksum]
|
|
213
|
+
except DoesNotExist:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _ensure_rev_transformation_row(checksum: str, result: str) -> None:
|
|
218
|
+
query = RevTransformation.select().where(
|
|
219
|
+
RevTransformation.checksum == checksum, RevTransformation.result == result
|
|
220
|
+
)
|
|
221
|
+
if query.exists():
|
|
222
|
+
return
|
|
223
|
+
RevTransformation.create(checksum=checksum, result=result)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _delete_rev_transformation_rows(checksum: str, result: str) -> None:
|
|
227
|
+
(
|
|
228
|
+
RevTransformation.delete()
|
|
229
|
+
.where(RevTransformation.checksum == checksum, RevTransformation.result == result)
|
|
230
|
+
.execute()
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _irreproducible_exists(checksum: str) -> bool:
|
|
235
|
+
query = IrreproducibleTransformation.select().where(
|
|
236
|
+
IrreproducibleTransformation.checksum == checksum
|
|
237
|
+
)
|
|
238
|
+
return query.exists()
|
|
239
|
+
|
|
240
|
+
|
|
138
241
|
def is_port_in_use(address, port):
|
|
139
242
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
140
243
|
return s.connect_ex((address, port)) == 0
|
|
@@ -244,8 +347,9 @@ types = (
|
|
|
244
347
|
"semantic_to_syntactic",
|
|
245
348
|
"transformation",
|
|
246
349
|
"metadata",
|
|
350
|
+
"bucket_probe",
|
|
247
351
|
"expression",
|
|
248
|
-
"irreproducible",
|
|
352
|
+
"irreproducible",
|
|
249
353
|
"rev_expression", # only GET
|
|
250
354
|
"rev_transformations", # only GET
|
|
251
355
|
)
|
|
@@ -273,7 +377,7 @@ def format_response(response, *, none_as_404=False):
|
|
|
273
377
|
|
|
274
378
|
class DatabaseServer:
|
|
275
379
|
future = None
|
|
276
|
-
PROTOCOL = ("seamless", "database", "2.
|
|
380
|
+
PROTOCOL = ("seamless", "database", "2.2")
|
|
277
381
|
|
|
278
382
|
def __init__(
|
|
279
383
|
self,
|
|
@@ -387,13 +491,15 @@ class DatabaseServer:
|
|
|
387
491
|
type_ = rq["type"]
|
|
388
492
|
if type_ not in types:
|
|
389
493
|
raise KeyError
|
|
390
|
-
if type_
|
|
494
|
+
if type_ not in ("protocol", "bucket_probe"):
|
|
391
495
|
checksum = rq["checksum"]
|
|
392
496
|
except KeyError:
|
|
393
497
|
raise DatabaseError("Malformed request") from None
|
|
394
498
|
|
|
395
499
|
if type_ == "protocol":
|
|
396
500
|
response = list(self.PROTOCOL)
|
|
501
|
+
elif type_ == "bucket_probe":
|
|
502
|
+
response = await self._get(type_, None, rq)
|
|
397
503
|
else:
|
|
398
504
|
try:
|
|
399
505
|
checksum = parse_checksum(checksum, as_bytes=False)
|
|
@@ -445,21 +551,27 @@ class DatabaseServer:
|
|
|
445
551
|
type_ = rq["type"]
|
|
446
552
|
if type_ not in types:
|
|
447
553
|
raise KeyError
|
|
448
|
-
|
|
554
|
+
if type_ != "bucket_probe":
|
|
555
|
+
checksum = rq["checksum"]
|
|
556
|
+
else:
|
|
557
|
+
checksum = None
|
|
449
558
|
except KeyError:
|
|
450
559
|
# import traceback; traceback.print_exc()
|
|
451
560
|
raise DatabaseError("Malformed request") from None
|
|
452
561
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
562
|
+
if type_ != "bucket_probe":
|
|
563
|
+
try:
|
|
564
|
+
checksum = parse_checksum(checksum, as_bytes=False)
|
|
565
|
+
except ValueError:
|
|
566
|
+
# import traceback; traceback.print_exc()
|
|
567
|
+
raise DatabaseError("Malformed request") from None
|
|
458
568
|
|
|
459
569
|
response = await self._put(type_, checksum, rq)
|
|
460
570
|
except DatabaseError as exc:
|
|
461
571
|
status = 400
|
|
462
572
|
response = "ERROR: " + exc.args[0]
|
|
573
|
+
if isinstance(response, web.Response):
|
|
574
|
+
return response
|
|
463
575
|
status2, response = format_response(response)
|
|
464
576
|
if status == 200 and status2 is not None:
|
|
465
577
|
status = status2
|
|
@@ -524,13 +636,59 @@ class DatabaseServer:
|
|
|
524
636
|
except DoesNotExist:
|
|
525
637
|
return None # None is also a valid response
|
|
526
638
|
|
|
639
|
+
elif type_ == "bucket_probe":
|
|
640
|
+
try:
|
|
641
|
+
bucket_kind = request["bucket_kind"]
|
|
642
|
+
label = request["label"]
|
|
643
|
+
except KeyError:
|
|
644
|
+
raise DatabaseError("Malformed bucket_probe request") from None
|
|
645
|
+
try:
|
|
646
|
+
row = BucketProbe.get(
|
|
647
|
+
BucketProbe.bucket_kind == bucket_kind,
|
|
648
|
+
BucketProbe.label == label,
|
|
649
|
+
)
|
|
650
|
+
except DoesNotExist:
|
|
651
|
+
return None
|
|
652
|
+
return {
|
|
653
|
+
"bucket_kind": row.bucket_kind,
|
|
654
|
+
"label": row.label,
|
|
655
|
+
"bucket_checksum": row.bucket_checksum,
|
|
656
|
+
"captured_at": row.captured_at,
|
|
657
|
+
"freshness_tokens": row.freshness_tokens,
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
elif type_ == "irreproducible":
|
|
661
|
+
result_checksum = request.get("result")
|
|
662
|
+
if result_checksum is not None:
|
|
663
|
+
try:
|
|
664
|
+
result_checksum = parse_checksum(result_checksum, as_bytes=False)
|
|
665
|
+
except ValueError:
|
|
666
|
+
raise DatabaseError("Malformed irreproducible request") from None
|
|
667
|
+
rows = (
|
|
668
|
+
IrreproducibleTransformation.select()
|
|
669
|
+
.where(IrreproducibleTransformation.checksum == checksum)
|
|
670
|
+
.execute()
|
|
671
|
+
)
|
|
672
|
+
result = []
|
|
673
|
+
for row in rows:
|
|
674
|
+
if result_checksum is not None and row.result != result_checksum:
|
|
675
|
+
continue
|
|
676
|
+
result.append(
|
|
677
|
+
{
|
|
678
|
+
"checksum": row.checksum,
|
|
679
|
+
"result": row.result,
|
|
680
|
+
"metadata": row.metadata,
|
|
681
|
+
}
|
|
682
|
+
)
|
|
683
|
+
if not result:
|
|
684
|
+
return None
|
|
685
|
+
return result
|
|
686
|
+
|
|
527
687
|
elif type_ == "expression":
|
|
528
688
|
try:
|
|
529
689
|
celltype = request["celltype"]
|
|
530
690
|
path = json.dumps(request["path"])
|
|
531
|
-
hash_pattern = json.dumps(request.get("hash_pattern", ""))
|
|
532
691
|
target_celltype = request["target_celltype"]
|
|
533
|
-
target_hash_pattern = json.dumps(request.get("target_hash_pattern", ""))
|
|
534
692
|
except KeyError:
|
|
535
693
|
raise DatabaseError("Malformed expression request")
|
|
536
694
|
result = (
|
|
@@ -539,9 +697,7 @@ class DatabaseServer:
|
|
|
539
697
|
Expression.input_checksum == checksum,
|
|
540
698
|
Expression.path == path,
|
|
541
699
|
Expression.celltype == celltype,
|
|
542
|
-
Expression.hash_pattern == hash_pattern,
|
|
543
700
|
Expression.target_celltype == target_celltype,
|
|
544
|
-
Expression.target_hash_pattern == target_hash_pattern,
|
|
545
701
|
)
|
|
546
702
|
.execute()
|
|
547
703
|
)
|
|
@@ -565,9 +721,7 @@ class DatabaseServer:
|
|
|
565
721
|
"checksum": expression.input_checksum,
|
|
566
722
|
"path": json.loads(expression.path),
|
|
567
723
|
"celltype": expression.celltype,
|
|
568
|
-
"hash_pattern": json.loads(expression.hash_pattern),
|
|
569
724
|
"target_celltype": expression.target_celltype,
|
|
570
|
-
"target_hash_pattern": json.loads(expression.target_hash_pattern),
|
|
571
725
|
"result": checksum,
|
|
572
726
|
}
|
|
573
727
|
result.append(expr)
|
|
@@ -645,9 +799,7 @@ class DatabaseServer:
|
|
|
645
799
|
value = parse_checksum(request["value"], as_bytes=False)
|
|
646
800
|
celltype = request["celltype"]
|
|
647
801
|
path = json.dumps(request["path"])
|
|
648
|
-
hash_pattern = json.dumps(request.get("hash_pattern", ""))
|
|
649
802
|
target_celltype = request["target_celltype"]
|
|
650
|
-
target_hash_pattern = json.dumps(request.get("target_hash_pattern", ""))
|
|
651
803
|
except KeyError:
|
|
652
804
|
raise DatabaseError("Malformed expression request")
|
|
653
805
|
try:
|
|
@@ -656,9 +808,7 @@ class DatabaseServer:
|
|
|
656
808
|
if len(request["path"]):
|
|
657
809
|
assert celltype in ("mixed", "plain", "binary")
|
|
658
810
|
assert len(celltype) <= 20
|
|
659
|
-
assert len(hash_pattern) <= 20
|
|
660
811
|
assert len(target_celltype) <= 20
|
|
661
|
-
assert len(target_hash_pattern) <= 20
|
|
662
812
|
except AssertionError:
|
|
663
813
|
raise DatabaseError(
|
|
664
814
|
"Malformed expression request (constraint violation)"
|
|
@@ -667,51 +817,124 @@ class DatabaseServer:
|
|
|
667
817
|
input_checksum=checksum,
|
|
668
818
|
path=path,
|
|
669
819
|
celltype=celltype,
|
|
670
|
-
hash_pattern=hash_pattern,
|
|
671
820
|
target_celltype=target_celltype,
|
|
672
|
-
target_hash_pattern=target_hash_pattern,
|
|
673
821
|
result=value,
|
|
674
822
|
)
|
|
675
823
|
|
|
676
824
|
elif type_ == "metadata":
|
|
677
825
|
try:
|
|
678
|
-
value = request["value"]
|
|
679
|
-
|
|
680
|
-
|
|
826
|
+
value = _normalize_metadata_value(request["value"])
|
|
827
|
+
result = parse_checksum(request["result"], as_bytes=False)
|
|
828
|
+
_validate_execution_record_identity(checksum, result, value)
|
|
829
|
+
except (KeyError, ValueError, TypeError):
|
|
681
830
|
raise DatabaseError("Malformed PUT metadata request") from None
|
|
682
|
-
|
|
831
|
+
with db_atomic():
|
|
832
|
+
if _irreproducible_exists(checksum):
|
|
833
|
+
return _conflict_response(
|
|
834
|
+
"Transformation already has irreproducible records"
|
|
835
|
+
)
|
|
836
|
+
tf_row = _get_transformation_row(checksum)
|
|
837
|
+
if tf_row is not None:
|
|
838
|
+
tf_result = parse_checksum(tf_row.result, as_bytes=False)
|
|
839
|
+
if tf_result != result:
|
|
840
|
+
return _conflict_response(
|
|
841
|
+
"Transformation result does not match execution record result"
|
|
842
|
+
)
|
|
843
|
+
metadata_row = _get_metadata_row(checksum)
|
|
844
|
+
if metadata_row is not None:
|
|
845
|
+
metadata_result = parse_checksum(metadata_row.result, as_bytes=False)
|
|
846
|
+
if metadata_result != result:
|
|
847
|
+
return _conflict_response(
|
|
848
|
+
"Execution record result does not match stored metadata result"
|
|
849
|
+
)
|
|
850
|
+
if metadata_row.metadata != value:
|
|
851
|
+
return _conflict_response(
|
|
852
|
+
"Execution record already exists with different metadata"
|
|
853
|
+
)
|
|
854
|
+
if tf_row is None:
|
|
855
|
+
Transformation.create(checksum=checksum, result=result)
|
|
856
|
+
_ensure_rev_transformation_row(checksum, result)
|
|
857
|
+
return "OK"
|
|
858
|
+
|
|
859
|
+
if tf_row is None:
|
|
860
|
+
Transformation.create(checksum=checksum, result=result)
|
|
861
|
+
_ensure_rev_transformation_row(checksum, result)
|
|
862
|
+
else:
|
|
863
|
+
_ensure_rev_transformation_row(checksum, result)
|
|
864
|
+
MetaData.create(checksum=checksum, result=result, metadata=value)
|
|
865
|
+
|
|
866
|
+
elif type_ == "bucket_probe":
|
|
867
|
+
try:
|
|
868
|
+
bucket_kind = request["bucket_kind"]
|
|
869
|
+
label = request["label"]
|
|
870
|
+
bucket_checksum = parse_checksum(
|
|
871
|
+
request["bucket_checksum"], as_bytes=False
|
|
872
|
+
)
|
|
873
|
+
freshness_tokens = _normalize_freshness_tokens(
|
|
874
|
+
request["freshness_tokens"]
|
|
875
|
+
)
|
|
876
|
+
captured_at = request["captured_at"]
|
|
877
|
+
_validate_bucket_probe_request(
|
|
878
|
+
bucket_kind,
|
|
879
|
+
label,
|
|
880
|
+
bucket_checksum,
|
|
881
|
+
freshness_tokens,
|
|
882
|
+
captured_at,
|
|
883
|
+
)
|
|
884
|
+
except (KeyError, ValueError, TypeError):
|
|
885
|
+
raise DatabaseError("Malformed PUT bucket_probe request") from None
|
|
886
|
+
with db_atomic():
|
|
887
|
+
query = BucketProbe.select().where(
|
|
888
|
+
BucketProbe.bucket_kind == bucket_kind,
|
|
889
|
+
BucketProbe.label == label,
|
|
890
|
+
)
|
|
891
|
+
if query.exists():
|
|
892
|
+
row = query.get()
|
|
893
|
+
row.bucket_checksum = bucket_checksum
|
|
894
|
+
row.captured_at = captured_at
|
|
895
|
+
row.freshness_tokens = freshness_tokens
|
|
896
|
+
row.save()
|
|
897
|
+
else:
|
|
898
|
+
BucketProbe.create(
|
|
899
|
+
bucket_kind=bucket_kind,
|
|
900
|
+
label=label,
|
|
901
|
+
bucket_checksum=bucket_checksum,
|
|
902
|
+
captured_at=captured_at,
|
|
903
|
+
freshness_tokens=freshness_tokens,
|
|
904
|
+
)
|
|
683
905
|
|
|
684
906
|
elif type_ == "irreproducible":
|
|
685
907
|
try:
|
|
686
908
|
result = parse_checksum(request["result"], as_bytes=False)
|
|
687
909
|
except (KeyError, ValueError):
|
|
688
910
|
raise DatabaseError("Malformed 'irreproducible' request") from None
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
911
|
+
with db_atomic():
|
|
912
|
+
tf_row = _get_transformation_row(checksum)
|
|
913
|
+
if tf_row is not None:
|
|
914
|
+
tf_result = parse_checksum(tf_row.result, as_bytes=False)
|
|
915
|
+
if tf_result != result:
|
|
916
|
+
return web.Response(
|
|
917
|
+
status=404,
|
|
918
|
+
text="ERROR: Transformation does not have the irreproducible result",
|
|
919
|
+
)
|
|
920
|
+
metadata_row = _get_metadata_row(checksum)
|
|
921
|
+
if metadata_row is not None:
|
|
922
|
+
metadata_result = parse_checksum(metadata_row.result, as_bytes=False)
|
|
923
|
+
if metadata_result != result:
|
|
924
|
+
return _conflict_response(
|
|
925
|
+
"Stored metadata result does not match irreproducible result"
|
|
926
|
+
)
|
|
927
|
+
metadata = metadata_row.metadata
|
|
928
|
+
else:
|
|
929
|
+
metadata = ""
|
|
930
|
+
IrreproducibleTransformation.create(
|
|
931
|
+
checksum=checksum, result=result, metadata=metadata
|
|
932
|
+
)
|
|
933
|
+
if tf_row is not None:
|
|
934
|
+
tf_row.delete_instance()
|
|
935
|
+
_delete_rev_transformation_rows(checksum, result)
|
|
936
|
+
if metadata_row is not None:
|
|
937
|
+
metadata_row.delete_instance()
|
|
715
938
|
else:
|
|
716
939
|
raise DatabaseError("Unknown request type")
|
|
717
940
|
return "OK"
|
|
@@ -775,21 +998,28 @@ If it doesn't exist, a new file is created.""",
|
|
|
775
998
|
create_tables=False,
|
|
776
999
|
)
|
|
777
1000
|
|
|
778
|
-
selected_port = args.port if args.port is not None else 5522
|
|
779
|
-
status_file_path = args.status_file
|
|
780
|
-
status_tracker = None
|
|
781
|
-
if status_file_path:
|
|
782
|
-
status_file_contents = wait_for_status_file(status_file_path)
|
|
783
|
-
status_tracker = StatusFileTracker(
|
|
784
|
-
status_file_path, status_file_contents, args.port
|
|
785
|
-
)
|
|
786
|
-
|
|
787
1001
|
if args.port_range:
|
|
788
1002
|
start, end = args.port_range
|
|
789
1003
|
try:
|
|
790
1004
|
selected_port = pick_random_free_port(args.host, start, end)
|
|
791
1005
|
except BaseException as exc:
|
|
792
1006
|
raise_startup_error(exc)
|
|
1007
|
+
elif args.port is not None:
|
|
1008
|
+
selected_port = args.port
|
|
1009
|
+
else:
|
|
1010
|
+
try:
|
|
1011
|
+
selected_port = pick_random_free_port(
|
|
1012
|
+
args.host, DEFAULT_RANDOM_PORT_START, DEFAULT_RANDOM_PORT_END
|
|
1013
|
+
)
|
|
1014
|
+
except BaseException as exc:
|
|
1015
|
+
raise_startup_error(exc)
|
|
1016
|
+
status_file_path = args.status_file
|
|
1017
|
+
status_tracker = None
|
|
1018
|
+
if status_file_path:
|
|
1019
|
+
status_file_contents = wait_for_status_file(status_file_path)
|
|
1020
|
+
status_tracker = StatusFileTracker(
|
|
1021
|
+
status_file_path, status_file_contents, selected_port
|
|
1022
|
+
)
|
|
793
1023
|
if status_tracker:
|
|
794
1024
|
status_tracker.port = selected_port
|
|
795
1025
|
|
|
@@ -831,3 +1061,7 @@ If it doesn't exist, a new file is created.""",
|
|
|
831
1061
|
raise
|
|
832
1062
|
finally:
|
|
833
1063
|
loop.run_until_complete(database_server.stop())
|
|
1064
|
+
BUCKET_KINDS = frozenset(
|
|
1065
|
+
("node", "environment", "node_env", "queue", "queue_node")
|
|
1066
|
+
)
|
|
1067
|
+
|
|
@@ -7,6 +7,7 @@ from peewee import (
|
|
|
7
7
|
CompositeKey,
|
|
8
8
|
IntegrityError,
|
|
9
9
|
)
|
|
10
|
+
import sqlite3
|
|
10
11
|
from playhouse.sqlite_ext import JSONField
|
|
11
12
|
|
|
12
13
|
|
|
@@ -134,9 +135,23 @@ class MetaData(BaseModel):
|
|
|
134
135
|
# - execution time (also if failed)
|
|
135
136
|
# - last recorded progress (if failed)
|
|
136
137
|
checksum = ChecksumField(primary_key=True)
|
|
138
|
+
result = ChecksumField(index=True, unique=False)
|
|
137
139
|
metadata = JSONField()
|
|
138
140
|
|
|
139
141
|
|
|
142
|
+
class BucketProbe(BaseModel):
|
|
143
|
+
bucket_kind = CharField(max_length=20)
|
|
144
|
+
label = TextField()
|
|
145
|
+
bucket_checksum = ChecksumField(index=True, unique=False)
|
|
146
|
+
captured_at = TextField()
|
|
147
|
+
freshness_tokens = JSONField()
|
|
148
|
+
|
|
149
|
+
class Meta:
|
|
150
|
+
database = _db
|
|
151
|
+
legacy_table_names = False
|
|
152
|
+
primary_key = CompositeKey("bucket_kind", "label")
|
|
153
|
+
|
|
154
|
+
|
|
140
155
|
class IrreproducibleTransformation(BaseModel):
|
|
141
156
|
result = ChecksumField(index=True, unique=False)
|
|
142
157
|
checksum = ChecksumField(index=True, unique=False)
|
|
@@ -150,6 +165,7 @@ _model_classes = [
|
|
|
150
165
|
SyntacticToSemantic,
|
|
151
166
|
Expression,
|
|
152
167
|
MetaData,
|
|
168
|
+
BucketProbe,
|
|
153
169
|
IrreproducibleTransformation,
|
|
154
170
|
]
|
|
155
171
|
_primary = {}
|
|
@@ -158,6 +174,8 @@ for model_class in _model_classes:
|
|
|
158
174
|
model_class is Expression
|
|
159
175
|
or model_class is SyntacticToSemantic
|
|
160
176
|
or model_class is RevTransformation
|
|
177
|
+
or model_class is MetaData
|
|
178
|
+
or model_class is BucketProbe
|
|
161
179
|
):
|
|
162
180
|
continue
|
|
163
181
|
for fieldname, field in model_class._meta.fields.items():
|
|
@@ -175,6 +193,8 @@ def db_init(
|
|
|
175
193
|
*,
|
|
176
194
|
create_tables: bool = True,
|
|
177
195
|
):
|
|
196
|
+
if not _db.is_closed():
|
|
197
|
+
_db.close()
|
|
178
198
|
if init_parameters is None:
|
|
179
199
|
init_parameters = {}
|
|
180
200
|
if connection_parameters is None:
|
|
@@ -182,7 +202,47 @@ def db_init(
|
|
|
182
202
|
_db.init(filename, **init_parameters)
|
|
183
203
|
_db.connect(**connection_parameters)
|
|
184
204
|
if create_tables:
|
|
205
|
+
_ensure_meta_data_schema()
|
|
185
206
|
_db.create_tables(_model_classes, safe=True)
|
|
186
207
|
|
|
187
208
|
|
|
188
209
|
db_atomic = _db.atomic
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _table_columns(table_name: str) -> list[str]:
|
|
213
|
+
cursor = _db.execute_sql(f"PRAGMA table_info({table_name})")
|
|
214
|
+
rows = cursor.fetchall()
|
|
215
|
+
return [row[1] for row in rows]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _table_rowcount(table_name: str) -> int:
|
|
219
|
+
cursor = _db.execute_sql(f"SELECT COUNT(*) FROM {table_name}")
|
|
220
|
+
row = cursor.fetchone()
|
|
221
|
+
assert row is not None
|
|
222
|
+
return int(row[0])
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _ensure_meta_data_schema() -> None:
|
|
226
|
+
table_name = MetaData._meta.table_name
|
|
227
|
+
try:
|
|
228
|
+
tables = set(_db.get_tables())
|
|
229
|
+
except sqlite3.OperationalError:
|
|
230
|
+
tables = set()
|
|
231
|
+
if table_name not in tables:
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
columns = _table_columns(table_name)
|
|
235
|
+
column_set = set(columns)
|
|
236
|
+
if len(columns) == 3 and column_set == {"checksum", "result", "metadata"}:
|
|
237
|
+
return
|
|
238
|
+
if len(columns) == 2 and column_set == {"checksum", "metadata"}:
|
|
239
|
+
if _table_rowcount(table_name) == 0:
|
|
240
|
+
_db.execute_sql(f"DROP TABLE {table_name}")
|
|
241
|
+
return
|
|
242
|
+
raise RuntimeError(
|
|
243
|
+
"Existing legacy 'meta_data' table is non-empty and cannot be upgraded automatically"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
raise RuntimeError(
|
|
247
|
+
f"Unsupported schema for '{table_name}': columns={columns!r}"
|
|
248
|
+
)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "seamless-database"
|
|
7
|
-
version = "2.0
|
|
7
|
+
version = "2.1.0"
|
|
8
8
|
description = "SQLite-backed metadata database service for Seamless"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -21,7 +21,6 @@ classifiers = [
|
|
|
21
21
|
"Intended Audience :: Developers",
|
|
22
22
|
"Programming Language :: Python :: 3",
|
|
23
23
|
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
-
"Programming Language :: Python :: 3.12",
|
|
25
24
|
"Operating System :: OS Independent",
|
|
26
25
|
"Topic :: Scientific/Engineering",
|
|
27
26
|
"Topic :: Software Development :: Libraries",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: seamless-database
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: SQLite-backed metadata database service for Seamless
|
|
5
5
|
Author: Sjoerd de Vries
|
|
6
6
|
License-Expression: MIT
|
|
@@ -13,7 +13,6 @@ Classifier: Intended Audience :: Science/Research
|
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: Programming Language :: Python :: 3
|
|
15
15
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
17
16
|
Classifier: Operating System :: OS Independent
|
|
18
17
|
Classifier: Topic :: Scientific/Engineering
|
|
19
18
|
Classifier: Topic :: Software Development :: Libraries
|
|
@@ -70,6 +69,27 @@ seamless-database seamless.db --port-range 5520 5530 --writable
|
|
|
70
69
|
seamless-database seamless.db --port 5522
|
|
71
70
|
```
|
|
72
71
|
|
|
72
|
+
If `--port` and `--port-range` are both omitted, `seamless-database` picks a random free port in the dynamic/private range (`49152-65535`).
|
|
73
|
+
|
|
74
|
+
### Status-file protocol
|
|
75
|
+
|
|
76
|
+
`seamless-database` does not require a status file. If `--status-file` is omitted, it runs independently.
|
|
77
|
+
|
|
78
|
+
If `--status-file` is provided, the file is used for two things:
|
|
79
|
+
|
|
80
|
+
1. Report the chosen port, especially when `--port-range` is used.
|
|
81
|
+
2. Report whether startup succeeded (`"running"`) or failed (`"failed"`).
|
|
82
|
+
|
|
83
|
+
The status-file protocol is simple:
|
|
84
|
+
|
|
85
|
+
1. Wait for the status file to exist and parse it as JSON.
|
|
86
|
+
2. Reuse the existing JSON object as the base payload. An empty JSON object `{}` is sufficient.
|
|
87
|
+
3. Choose or validate its listening port.
|
|
88
|
+
4. Once the HTTP server is up, rewrite the same file with `"status": "running"` and the selected `"port"`.
|
|
89
|
+
5. If startup fails before the server is running, rewrite the file with `"status": "failed"` instead.
|
|
90
|
+
|
|
91
|
+
If `remote-http-launcher` is used, it may pre-populate the JSON with fields such as the PID, workdir, or `"status": "starting"`. `seamless-database` preserves such fields when it writes back the final status.
|
|
92
|
+
|
|
73
93
|
### CLI options
|
|
74
94
|
|
|
75
95
|
| Option | Description |
|
|
@@ -8,4 +8,6 @@ seamless_database.egg-info/SOURCES.txt
|
|
|
8
8
|
seamless_database.egg-info/dependency_links.txt
|
|
9
9
|
seamless_database.egg-info/entry_points.txt
|
|
10
10
|
seamless_database.egg-info/requires.txt
|
|
11
|
-
seamless_database.egg-info/top_level.txt
|
|
11
|
+
seamless_database.egg-info/top_level.txt
|
|
12
|
+
tests/test_execution_records.py
|
|
13
|
+
tests/test_random_port.py
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import sqlite3
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
10
|
+
DATABASE_DIR = ROOT / "seamless-database"
|
|
11
|
+
if str(DATABASE_DIR) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(DATABASE_DIR))
|
|
13
|
+
|
|
14
|
+
from database import DatabaseServer # noqa: E402
|
|
15
|
+
from database_models import ( # noqa: E402
|
|
16
|
+
BucketProbe,
|
|
17
|
+
MetaData,
|
|
18
|
+
RevTransformation,
|
|
19
|
+
Transformation,
|
|
20
|
+
IrreproducibleTransformation,
|
|
21
|
+
_db,
|
|
22
|
+
db_init,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
TF_CHECKSUM = "1" * 64
|
|
27
|
+
RESULT_CHECKSUM = "2" * 64
|
|
28
|
+
BUCKET_CHECKSUM = "3" * 64
|
|
29
|
+
BUCKET_CHECKSUM_2 = "4" * 64
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _close_db():
|
|
33
|
+
if not _db.is_closed():
|
|
34
|
+
_db.close()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _init_db(path: Path):
|
|
38
|
+
_close_db()
|
|
39
|
+
db_init(str(path))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _record(
|
|
43
|
+
tf_checksum: str = TF_CHECKSUM,
|
|
44
|
+
result_checksum: str = RESULT_CHECKSUM,
|
|
45
|
+
schema_version=1,
|
|
46
|
+
):
|
|
47
|
+
return {
|
|
48
|
+
"schema_version": schema_version,
|
|
49
|
+
"checksum_fields": ["node", "environment"],
|
|
50
|
+
"tf_checksum": tf_checksum,
|
|
51
|
+
"result_checksum": result_checksum,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _minimal_record(
|
|
56
|
+
tf_checksum: str = TF_CHECKSUM,
|
|
57
|
+
result_checksum: str = RESULT_CHECKSUM,
|
|
58
|
+
):
|
|
59
|
+
return {
|
|
60
|
+
"schema_version": 1,
|
|
61
|
+
"tf_checksum": tf_checksum,
|
|
62
|
+
"result_checksum": result_checksum,
|
|
63
|
+
"seamless_version": "test",
|
|
64
|
+
"execution_mode": "process",
|
|
65
|
+
"remote_target": None,
|
|
66
|
+
"wall_time_seconds": 0.5,
|
|
67
|
+
"cpu_time_user_seconds": 0.2,
|
|
68
|
+
"cpu_time_system_seconds": 0.1,
|
|
69
|
+
"memory_peak_bytes": 12345,
|
|
70
|
+
"gpu_memory_peak_bytes": None,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_db_init_recreates_empty_legacy_metadata_table(tmp_path):
|
|
75
|
+
dbfile = tmp_path / "legacy-empty.db"
|
|
76
|
+
con = sqlite3.connect(dbfile)
|
|
77
|
+
try:
|
|
78
|
+
con.execute("CREATE TABLE meta_data (checksum TEXT PRIMARY KEY, metadata TEXT)")
|
|
79
|
+
con.commit()
|
|
80
|
+
finally:
|
|
81
|
+
con.close()
|
|
82
|
+
|
|
83
|
+
_init_db(dbfile)
|
|
84
|
+
try:
|
|
85
|
+
con = sqlite3.connect(dbfile)
|
|
86
|
+
try:
|
|
87
|
+
columns = [
|
|
88
|
+
row[1]
|
|
89
|
+
for row in con.execute("PRAGMA table_info(meta_data)").fetchall()
|
|
90
|
+
]
|
|
91
|
+
finally:
|
|
92
|
+
con.close()
|
|
93
|
+
assert columns == ["checksum", "result", "metadata"]
|
|
94
|
+
finally:
|
|
95
|
+
_close_db()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_db_init_rejects_nonempty_legacy_metadata_table(tmp_path):
|
|
99
|
+
dbfile = tmp_path / "legacy-nonempty.db"
|
|
100
|
+
con = sqlite3.connect(dbfile)
|
|
101
|
+
try:
|
|
102
|
+
con.execute("CREATE TABLE meta_data (checksum TEXT PRIMARY KEY, metadata TEXT)")
|
|
103
|
+
con.execute(
|
|
104
|
+
"INSERT INTO meta_data(checksum, metadata) VALUES(?, ?)",
|
|
105
|
+
(TF_CHECKSUM, "{}"),
|
|
106
|
+
)
|
|
107
|
+
con.commit()
|
|
108
|
+
finally:
|
|
109
|
+
con.close()
|
|
110
|
+
|
|
111
|
+
with pytest.raises(RuntimeError, match="legacy 'meta_data' table is non-empty"):
|
|
112
|
+
_init_db(dbfile)
|
|
113
|
+
_close_db()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_put_metadata_auto_creates_and_gets_record(tmp_path):
|
|
117
|
+
dbfile = tmp_path / "records.db"
|
|
118
|
+
_init_db(dbfile)
|
|
119
|
+
server = DatabaseServer("127.0.0.1", 0)
|
|
120
|
+
record = _record()
|
|
121
|
+
request = {
|
|
122
|
+
"type": "metadata",
|
|
123
|
+
"checksum": TF_CHECKSUM,
|
|
124
|
+
"result": RESULT_CHECKSUM,
|
|
125
|
+
"value": record,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
result = asyncio.run(server._put("metadata", TF_CHECKSUM, request))
|
|
130
|
+
assert result == "OK"
|
|
131
|
+
assert Transformation[TF_CHECKSUM].result == RESULT_CHECKSUM
|
|
132
|
+
assert MetaData[TF_CHECKSUM].result == RESULT_CHECKSUM
|
|
133
|
+
assert (
|
|
134
|
+
asyncio.run(
|
|
135
|
+
server._get(
|
|
136
|
+
"metadata", TF_CHECKSUM, {"type": "metadata", "checksum": TF_CHECKSUM}
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
== record
|
|
140
|
+
)
|
|
141
|
+
finally:
|
|
142
|
+
_close_db()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_put_metadata_accepts_minimal_execution_record(tmp_path):
|
|
146
|
+
dbfile = tmp_path / "minimal-records.db"
|
|
147
|
+
_init_db(dbfile)
|
|
148
|
+
server = DatabaseServer("127.0.0.1", 0)
|
|
149
|
+
record = _minimal_record()
|
|
150
|
+
request = {
|
|
151
|
+
"type": "metadata",
|
|
152
|
+
"checksum": TF_CHECKSUM,
|
|
153
|
+
"result": RESULT_CHECKSUM,
|
|
154
|
+
"value": record,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
result = asyncio.run(server._put("metadata", TF_CHECKSUM, request))
|
|
159
|
+
assert result == "OK"
|
|
160
|
+
assert MetaData[TF_CHECKSUM].metadata == record
|
|
161
|
+
finally:
|
|
162
|
+
_close_db()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_put_metadata_is_idempotent_and_rejects_conflicts(tmp_path):
|
|
166
|
+
dbfile = tmp_path / "records-conflict.db"
|
|
167
|
+
_init_db(dbfile)
|
|
168
|
+
server = DatabaseServer("127.0.0.1", 0)
|
|
169
|
+
record = _record()
|
|
170
|
+
request = {
|
|
171
|
+
"type": "metadata",
|
|
172
|
+
"checksum": TF_CHECKSUM,
|
|
173
|
+
"result": RESULT_CHECKSUM,
|
|
174
|
+
"value": record,
|
|
175
|
+
}
|
|
176
|
+
bad_request = {
|
|
177
|
+
"type": "metadata",
|
|
178
|
+
"checksum": TF_CHECKSUM,
|
|
179
|
+
"result": RESULT_CHECKSUM,
|
|
180
|
+
"value": {
|
|
181
|
+
**record,
|
|
182
|
+
"checksum_fields": ["node"],
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
assert asyncio.run(server._put("metadata", TF_CHECKSUM, request)) == "OK"
|
|
188
|
+
assert asyncio.run(server._put("metadata", TF_CHECKSUM, request)) == "OK"
|
|
189
|
+
response = asyncio.run(server._put("metadata", TF_CHECKSUM, bad_request))
|
|
190
|
+
assert response.status == 409
|
|
191
|
+
finally:
|
|
192
|
+
_close_db()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_put_irreproducible_moves_metadata_and_deletes_normal_rows(tmp_path):
|
|
196
|
+
dbfile = tmp_path / "irreproducible.db"
|
|
197
|
+
_init_db(dbfile)
|
|
198
|
+
server = DatabaseServer("127.0.0.1", 0)
|
|
199
|
+
record = _record()
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
asyncio.run(
|
|
203
|
+
server._put(
|
|
204
|
+
"metadata",
|
|
205
|
+
TF_CHECKSUM,
|
|
206
|
+
{
|
|
207
|
+
"type": "metadata",
|
|
208
|
+
"checksum": TF_CHECKSUM,
|
|
209
|
+
"result": RESULT_CHECKSUM,
|
|
210
|
+
"value": record,
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
assert RevTransformation.select().count() == 1
|
|
215
|
+
result = asyncio.run(
|
|
216
|
+
server._put(
|
|
217
|
+
"irreproducible",
|
|
218
|
+
TF_CHECKSUM,
|
|
219
|
+
{
|
|
220
|
+
"type": "irreproducible",
|
|
221
|
+
"checksum": TF_CHECKSUM,
|
|
222
|
+
"result": RESULT_CHECKSUM,
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
assert result == "OK"
|
|
227
|
+
assert Transformation.select().count() == 0
|
|
228
|
+
assert MetaData.select().count() == 0
|
|
229
|
+
assert RevTransformation.select().count() == 0
|
|
230
|
+
rows = asyncio.run(
|
|
231
|
+
server._get(
|
|
232
|
+
"irreproducible",
|
|
233
|
+
TF_CHECKSUM,
|
|
234
|
+
{"type": "irreproducible", "checksum": TF_CHECKSUM},
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
assert rows == [
|
|
238
|
+
{
|
|
239
|
+
"checksum": TF_CHECKSUM,
|
|
240
|
+
"result": RESULT_CHECKSUM,
|
|
241
|
+
"metadata": record,
|
|
242
|
+
}
|
|
243
|
+
]
|
|
244
|
+
response = asyncio.run(
|
|
245
|
+
server._put(
|
|
246
|
+
"metadata",
|
|
247
|
+
TF_CHECKSUM,
|
|
248
|
+
{
|
|
249
|
+
"type": "metadata",
|
|
250
|
+
"checksum": TF_CHECKSUM,
|
|
251
|
+
"result": RESULT_CHECKSUM,
|
|
252
|
+
"value": record,
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
assert response.status == 409
|
|
257
|
+
assert IrreproducibleTransformation.select().count() == 1
|
|
258
|
+
finally:
|
|
259
|
+
_close_db()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_bucket_probe_roundtrip_and_overwrite(tmp_path):
|
|
263
|
+
dbfile = tmp_path / "bucket-probe.db"
|
|
264
|
+
_init_db(dbfile)
|
|
265
|
+
server = DatabaseServer("127.0.0.1", 0)
|
|
266
|
+
request = {
|
|
267
|
+
"type": "bucket_probe",
|
|
268
|
+
"bucket_kind": "environment",
|
|
269
|
+
"label": "conda:/envs/seamless1",
|
|
270
|
+
"bucket_checksum": BUCKET_CHECKSUM,
|
|
271
|
+
"captured_at": "2026-04-26T12:00:00Z",
|
|
272
|
+
"freshness_tokens": {"conda_meta_mtime": 123},
|
|
273
|
+
}
|
|
274
|
+
expected_probe = {k: v for k, v in request.items() if k != "type"}
|
|
275
|
+
updated_request = {
|
|
276
|
+
**request,
|
|
277
|
+
"bucket_checksum": BUCKET_CHECKSUM_2,
|
|
278
|
+
"captured_at": "2026-04-26T12:05:00Z",
|
|
279
|
+
"freshness_tokens": {"conda_meta_mtime": 456},
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
assert asyncio.run(server._put("bucket_probe", None, request)) == "OK"
|
|
284
|
+
probe = asyncio.run(
|
|
285
|
+
server._get(
|
|
286
|
+
"bucket_probe",
|
|
287
|
+
None,
|
|
288
|
+
{
|
|
289
|
+
"type": "bucket_probe",
|
|
290
|
+
"bucket_kind": request["bucket_kind"],
|
|
291
|
+
"label": request["label"],
|
|
292
|
+
},
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
assert probe == expected_probe
|
|
296
|
+
assert BucketProbe.select().count() == 1
|
|
297
|
+
|
|
298
|
+
assert asyncio.run(server._put("bucket_probe", None, updated_request)) == "OK"
|
|
299
|
+
probe = asyncio.run(
|
|
300
|
+
server._get(
|
|
301
|
+
"bucket_probe",
|
|
302
|
+
None,
|
|
303
|
+
{
|
|
304
|
+
"type": "bucket_probe",
|
|
305
|
+
"bucket_kind": request["bucket_kind"],
|
|
306
|
+
"label": request["label"],
|
|
307
|
+
},
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
assert probe == {k: v for k, v in updated_request.items() if k != "type"}
|
|
311
|
+
assert BucketProbe.select().count() == 1
|
|
312
|
+
finally:
|
|
313
|
+
_close_db()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import signal
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.error import URLError
|
|
8
|
+
from urllib.request import urlopen
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_RANDOM_PORT_START = 49152
|
|
12
|
+
DEFAULT_RANDOM_PORT_END = 65535
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def wait_for_status_file(status_file: Path, timeout: float = 10.0):
|
|
16
|
+
deadline = time.monotonic() + timeout
|
|
17
|
+
last_error = None
|
|
18
|
+
while time.monotonic() < deadline:
|
|
19
|
+
try:
|
|
20
|
+
payload = json.loads(status_file.read_text())
|
|
21
|
+
except (FileNotFoundError, json.JSONDecodeError) as exc:
|
|
22
|
+
last_error = exc
|
|
23
|
+
time.sleep(0.1)
|
|
24
|
+
continue
|
|
25
|
+
if payload.get("status") == "running" and isinstance(payload.get("port"), int):
|
|
26
|
+
return payload
|
|
27
|
+
last_error = payload
|
|
28
|
+
time.sleep(0.1)
|
|
29
|
+
raise RuntimeError(f"Database status file did not report running state: {last_error}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def wait_for_server(port: int, timeout: float = 10.0):
|
|
33
|
+
deadline = time.monotonic() + timeout
|
|
34
|
+
url = f"http://127.0.0.1:{port}/healthcheck"
|
|
35
|
+
last_error = None
|
|
36
|
+
while time.monotonic() < deadline:
|
|
37
|
+
try:
|
|
38
|
+
with urlopen(url, timeout=1.0) as response: # noqa: S310 - local test server
|
|
39
|
+
if response.status == 200 and response.read().decode() == "OK":
|
|
40
|
+
return
|
|
41
|
+
last_error = response.status
|
|
42
|
+
except URLError as exc:
|
|
43
|
+
last_error = exc
|
|
44
|
+
time.sleep(0.1)
|
|
45
|
+
raise RuntimeError(f"Database server at {url} did not become ready: {last_error}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_random_port_default(tmp_path):
|
|
49
|
+
database_file = tmp_path / "seamless.db"
|
|
50
|
+
status_file = tmp_path / "status.json"
|
|
51
|
+
status_file.write_text("{}\n")
|
|
52
|
+
command = [
|
|
53
|
+
"seamless-database",
|
|
54
|
+
str(database_file),
|
|
55
|
+
"--writable",
|
|
56
|
+
"--status-file",
|
|
57
|
+
str(status_file),
|
|
58
|
+
]
|
|
59
|
+
process = subprocess.Popen( # noqa: S603,S607 - controlled test command
|
|
60
|
+
command,
|
|
61
|
+
stdout=subprocess.PIPE,
|
|
62
|
+
stderr=subprocess.STDOUT,
|
|
63
|
+
text=True,
|
|
64
|
+
env=os.environ.copy(),
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
payload = wait_for_status_file(status_file)
|
|
68
|
+
port = payload["port"]
|
|
69
|
+
assert DEFAULT_RANDOM_PORT_START <= port <= DEFAULT_RANDOM_PORT_END, port
|
|
70
|
+
assert port != 5522
|
|
71
|
+
wait_for_server(port)
|
|
72
|
+
finally:
|
|
73
|
+
if process.poll() is None:
|
|
74
|
+
process.send_signal(signal.SIGINT)
|
|
75
|
+
try:
|
|
76
|
+
stdout, _ = process.communicate(timeout=5)
|
|
77
|
+
except subprocess.TimeoutExpired:
|
|
78
|
+
process.kill()
|
|
79
|
+
stdout, _ = process.communicate()
|
|
80
|
+
if stdout:
|
|
81
|
+
print("Server logs:")
|
|
82
|
+
print(stdout, end="" if stdout.endswith("\n") else "\n")
|
{seamless_database-2.0.1 → seamless_database-2.1.0}/seamless_database.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{seamless_database-2.0.1 → seamless_database-2.1.0}/seamless_database.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{seamless_database-2.0.1 → seamless_database-2.1.0}/seamless_database.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|