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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seamless-database
3
- Version: 2.0.2
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
@@ -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", # only PUT
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.0")
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_ != "protocol":
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
- checksum = rq["checksum"]
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
- try:
457
- checksum = parse_checksum(checksum, as_bytes=False)
458
- except ValueError:
459
- # import traceback; traceback.print_exc()
460
- raise DatabaseError("Malformed request") from None
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
- value = json.loads(value)
683
- except (KeyError, ValueError):
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
- MetaData.create(checksum=checksum, metadata=value)
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
- in_transformations = False
693
- try:
694
- tf = Transformation[checksum]
695
- tf_result = parse_checksum(tf.result, as_bytes=False)
696
- in_transformations = True
697
- except DoesNotExist:
698
- pass
699
- if in_transformations:
700
- if tf_result != result:
701
- return web.Response(
702
- status=404,
703
- reason="Transformation does not have the irreproducible result",
704
- )
705
- try:
706
- metadata = MetaData[checksum].metadata
707
- in_metadata = True
708
- except DoesNotExist:
709
- metadata = ""
710
- in_metadata = False
711
- IrreproducibleTransformation.create(
712
- checksum=checksum, result=result, metadata=metadata
713
- )
714
- if in_transformations:
715
- tf.delete_instance()
716
- if in_metadata:
717
- MetaData[checksum].delete_instance()
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
+ )
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "seamless-database"
7
- version = "2.0.2"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seamless-database
3
- Version: 2.0.2
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
@@ -9,4 +9,5 @@ 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
11
  seamless_database.egg-info/top_level.txt
12
+ tests/test_execution_records.py
12
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()