seamless-database 2.0.2__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.2 → seamless_database-2.1.0}/PKG-INFO +1 -1
- {seamless_database-2.0.2 → seamless_database-2.1.0}/database.py +275 -51
- {seamless_database-2.0.2 → seamless_database-2.1.0}/database_models.py +60 -0
- {seamless_database-2.0.2 → seamless_database-2.1.0}/pyproject.toml +1 -1
- {seamless_database-2.0.2 → seamless_database-2.1.0}/seamless_database.egg-info/PKG-INFO +1 -1
- {seamless_database-2.0.2 → seamless_database-2.1.0}/seamless_database.egg-info/SOURCES.txt +1 -0
- seamless_database-2.1.0/tests/test_execution_records.py +313 -0
- {seamless_database-2.0.2 → seamless_database-2.1.0}/README.md +0 -0
- {seamless_database-2.0.2 → seamless_database-2.1.0}/seamless_database.egg-info/dependency_links.txt +0 -0
- {seamless_database-2.0.2 → seamless_database-2.1.0}/seamless_database.egg-info/entry_points.txt +0 -0
- {seamless_database-2.0.2 → seamless_database-2.1.0}/seamless_database.egg-info/requires.txt +0 -0
- {seamless_database-2.0.2 → seamless_database-2.1.0}/seamless_database.egg-info/top_level.txt +0 -0
- {seamless_database-2.0.2 → seamless_database-2.1.0}/setup.cfg +0 -0
- {seamless_database-2.0.2 → seamless_database-2.1.0}/tests/test_random_port.py +0 -0
|
@@ -20,6 +20,7 @@ from database_models import (
|
|
|
20
20
|
SyntacticToSemantic,
|
|
21
21
|
Expression,
|
|
22
22
|
MetaData,
|
|
23
|
+
BucketProbe,
|
|
23
24
|
IrreproducibleTransformation,
|
|
24
25
|
)
|
|
25
26
|
|
|
@@ -138,6 +139,105 @@ class DatabaseError(Exception):
|
|
|
138
139
|
pass
|
|
139
140
|
|
|
140
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
|
+
|
|
141
241
|
def is_port_in_use(address, port):
|
|
142
242
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
143
243
|
return s.connect_ex((address, port)) == 0
|
|
@@ -247,8 +347,9 @@ types = (
|
|
|
247
347
|
"semantic_to_syntactic",
|
|
248
348
|
"transformation",
|
|
249
349
|
"metadata",
|
|
350
|
+
"bucket_probe",
|
|
250
351
|
"expression",
|
|
251
|
-
"irreproducible",
|
|
352
|
+
"irreproducible",
|
|
252
353
|
"rev_expression", # only GET
|
|
253
354
|
"rev_transformations", # only GET
|
|
254
355
|
)
|
|
@@ -276,7 +377,7 @@ def format_response(response, *, none_as_404=False):
|
|
|
276
377
|
|
|
277
378
|
class DatabaseServer:
|
|
278
379
|
future = None
|
|
279
|
-
PROTOCOL = ("seamless", "database", "2.
|
|
380
|
+
PROTOCOL = ("seamless", "database", "2.2")
|
|
280
381
|
|
|
281
382
|
def __init__(
|
|
282
383
|
self,
|
|
@@ -390,13 +491,15 @@ class DatabaseServer:
|
|
|
390
491
|
type_ = rq["type"]
|
|
391
492
|
if type_ not in types:
|
|
392
493
|
raise KeyError
|
|
393
|
-
if type_
|
|
494
|
+
if type_ not in ("protocol", "bucket_probe"):
|
|
394
495
|
checksum = rq["checksum"]
|
|
395
496
|
except KeyError:
|
|
396
497
|
raise DatabaseError("Malformed request") from None
|
|
397
498
|
|
|
398
499
|
if type_ == "protocol":
|
|
399
500
|
response = list(self.PROTOCOL)
|
|
501
|
+
elif type_ == "bucket_probe":
|
|
502
|
+
response = await self._get(type_, None, rq)
|
|
400
503
|
else:
|
|
401
504
|
try:
|
|
402
505
|
checksum = parse_checksum(checksum, as_bytes=False)
|
|
@@ -448,21 +551,27 @@ class DatabaseServer:
|
|
|
448
551
|
type_ = rq["type"]
|
|
449
552
|
if type_ not in types:
|
|
450
553
|
raise KeyError
|
|
451
|
-
|
|
554
|
+
if type_ != "bucket_probe":
|
|
555
|
+
checksum = rq["checksum"]
|
|
556
|
+
else:
|
|
557
|
+
checksum = None
|
|
452
558
|
except KeyError:
|
|
453
559
|
# import traceback; traceback.print_exc()
|
|
454
560
|
raise DatabaseError("Malformed request") from None
|
|
455
561
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
461
568
|
|
|
462
569
|
response = await self._put(type_, checksum, rq)
|
|
463
570
|
except DatabaseError as exc:
|
|
464
571
|
status = 400
|
|
465
572
|
response = "ERROR: " + exc.args[0]
|
|
573
|
+
if isinstance(response, web.Response):
|
|
574
|
+
return response
|
|
466
575
|
status2, response = format_response(response)
|
|
467
576
|
if status == 200 and status2 is not None:
|
|
468
577
|
status = status2
|
|
@@ -527,13 +636,59 @@ class DatabaseServer:
|
|
|
527
636
|
except DoesNotExist:
|
|
528
637
|
return None # None is also a valid response
|
|
529
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
|
+
|
|
530
687
|
elif type_ == "expression":
|
|
531
688
|
try:
|
|
532
689
|
celltype = request["celltype"]
|
|
533
690
|
path = json.dumps(request["path"])
|
|
534
|
-
hash_pattern = json.dumps(request.get("hash_pattern", ""))
|
|
535
691
|
target_celltype = request["target_celltype"]
|
|
536
|
-
target_hash_pattern = json.dumps(request.get("target_hash_pattern", ""))
|
|
537
692
|
except KeyError:
|
|
538
693
|
raise DatabaseError("Malformed expression request")
|
|
539
694
|
result = (
|
|
@@ -542,9 +697,7 @@ class DatabaseServer:
|
|
|
542
697
|
Expression.input_checksum == checksum,
|
|
543
698
|
Expression.path == path,
|
|
544
699
|
Expression.celltype == celltype,
|
|
545
|
-
Expression.hash_pattern == hash_pattern,
|
|
546
700
|
Expression.target_celltype == target_celltype,
|
|
547
|
-
Expression.target_hash_pattern == target_hash_pattern,
|
|
548
701
|
)
|
|
549
702
|
.execute()
|
|
550
703
|
)
|
|
@@ -568,9 +721,7 @@ class DatabaseServer:
|
|
|
568
721
|
"checksum": expression.input_checksum,
|
|
569
722
|
"path": json.loads(expression.path),
|
|
570
723
|
"celltype": expression.celltype,
|
|
571
|
-
"hash_pattern": json.loads(expression.hash_pattern),
|
|
572
724
|
"target_celltype": expression.target_celltype,
|
|
573
|
-
"target_hash_pattern": json.loads(expression.target_hash_pattern),
|
|
574
725
|
"result": checksum,
|
|
575
726
|
}
|
|
576
727
|
result.append(expr)
|
|
@@ -648,9 +799,7 @@ class DatabaseServer:
|
|
|
648
799
|
value = parse_checksum(request["value"], as_bytes=False)
|
|
649
800
|
celltype = request["celltype"]
|
|
650
801
|
path = json.dumps(request["path"])
|
|
651
|
-
hash_pattern = json.dumps(request.get("hash_pattern", ""))
|
|
652
802
|
target_celltype = request["target_celltype"]
|
|
653
|
-
target_hash_pattern = json.dumps(request.get("target_hash_pattern", ""))
|
|
654
803
|
except KeyError:
|
|
655
804
|
raise DatabaseError("Malformed expression request")
|
|
656
805
|
try:
|
|
@@ -659,9 +808,7 @@ class DatabaseServer:
|
|
|
659
808
|
if len(request["path"]):
|
|
660
809
|
assert celltype in ("mixed", "plain", "binary")
|
|
661
810
|
assert len(celltype) <= 20
|
|
662
|
-
assert len(hash_pattern) <= 20
|
|
663
811
|
assert len(target_celltype) <= 20
|
|
664
|
-
assert len(target_hash_pattern) <= 20
|
|
665
812
|
except AssertionError:
|
|
666
813
|
raise DatabaseError(
|
|
667
814
|
"Malformed expression request (constraint violation)"
|
|
@@ -670,51 +817,124 @@ class DatabaseServer:
|
|
|
670
817
|
input_checksum=checksum,
|
|
671
818
|
path=path,
|
|
672
819
|
celltype=celltype,
|
|
673
|
-
hash_pattern=hash_pattern,
|
|
674
820
|
target_celltype=target_celltype,
|
|
675
|
-
target_hash_pattern=target_hash_pattern,
|
|
676
821
|
result=value,
|
|
677
822
|
)
|
|
678
823
|
|
|
679
824
|
elif type_ == "metadata":
|
|
680
825
|
try:
|
|
681
|
-
value = request["value"]
|
|
682
|
-
|
|
683
|
-
|
|
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):
|
|
684
830
|
raise DatabaseError("Malformed PUT metadata request") from None
|
|
685
|
-
|
|
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
|
+
)
|
|
686
905
|
|
|
687
906
|
elif type_ == "irreproducible":
|
|
688
907
|
try:
|
|
689
908
|
result = parse_checksum(request["result"], as_bytes=False)
|
|
690
909
|
except (KeyError, ValueError):
|
|
691
910
|
raise DatabaseError("Malformed 'irreproducible' request") from None
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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()
|
|
718
938
|
else:
|
|
719
939
|
raise DatabaseError("Unknown request type")
|
|
720
940
|
return "OK"
|
|
@@ -841,3 +1061,7 @@ If it doesn't exist, a new file is created.""",
|
|
|
841
1061
|
raise
|
|
842
1062
|
finally:
|
|
843
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
|
+
)
|
|
@@ -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()
|
|
File without changes
|
{seamless_database-2.0.2 → seamless_database-2.1.0}/seamless_database.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{seamless_database-2.0.2 → seamless_database-2.1.0}/seamless_database.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{seamless_database-2.0.2 → seamless_database-2.1.0}/seamless_database.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|