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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seamless-database
3
- Version: 2.0.1
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", # only PUT
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.0")
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_ != "protocol":
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
- checksum = rq["checksum"]
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
- try:
454
- checksum = parse_checksum(checksum, as_bytes=False)
455
- except ValueError:
456
- # import traceback; traceback.print_exc()
457
- 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
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
- value = json.loads(value)
680
- 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):
681
830
  raise DatabaseError("Malformed PUT metadata request") from None
682
- 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
+ )
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
- in_transformations = False
690
- try:
691
- tf = Transformation[checksum]
692
- tf_result = parse_checksum(tf.result, as_bytes=False)
693
- in_transformations = True
694
- except DoesNotExist:
695
- pass
696
- if in_transformations:
697
- if tf_result != result:
698
- return web.Response(
699
- status=404,
700
- reason="Transformation does not have the irreproducible result",
701
- )
702
- try:
703
- metadata = MetaData[checksum].metadata
704
- in_metadata = True
705
- except DoesNotExist:
706
- metadata = ""
707
- in_metadata = False
708
- IrreproducibleTransformation.create(
709
- checksum=checksum, result=result, metadata=metadata
710
- )
711
- if in_transformations:
712
- tf.delete_instance()
713
- if in_metadata:
714
- 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()
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.1"
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.1
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")