dbos 2.4.0a3__py3-none-any.whl → 2.6.0a8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of dbos might be problematic. Click here for more details.

dbos/_sys_db.py CHANGED
@@ -158,6 +158,16 @@ class WorkflowStatusInternal(TypedDict):
158
158
  forked_from: Optional[str]
159
159
 
160
160
 
161
+ class MetricData(TypedDict):
162
+ """
163
+ Metrics data for workflows and steps within a time range.
164
+ """
165
+
166
+ metric_type: str # Type of metric: "workflow" or "step"
167
+ metric_name: str # Name of the workflow or step
168
+ value: int # Number of times the operation ran in the time interval
169
+
170
+
161
171
  class EnqueueOptionsInternal(TypedDict):
162
172
  # Unique ID for deduplication on a queue
163
173
  deduplication_id: Optional[str]
@@ -170,16 +180,17 @@ class EnqueueOptionsInternal(TypedDict):
170
180
 
171
181
 
172
182
  class RecordedResult(TypedDict):
173
- output: Optional[str] # JSON (jsonpickle)
174
- error: Optional[str] # JSON (jsonpickle)
183
+ output: Optional[str] # Serialized
184
+ error: Optional[str] # Serialized
185
+ child_workflow_id: Optional[str]
175
186
 
176
187
 
177
188
  class OperationResultInternal(TypedDict):
178
189
  workflow_uuid: str
179
190
  function_id: int
180
191
  function_name: str
181
- output: Optional[str] # JSON (jsonpickle)
182
- error: Optional[str] # JSON (jsonpickle)
192
+ output: Optional[str] # Serialized
193
+ error: Optional[str] # Serialized
183
194
  started_at_epoch_ms: int
184
195
 
185
196
 
@@ -351,6 +362,7 @@ class SystemDatabase(ABC):
351
362
  engine: Optional[sa.Engine],
352
363
  schema: Optional[str],
353
364
  serializer: Serializer,
365
+ executor_id: Optional[str],
354
366
  debug_mode: bool = False,
355
367
  ) -> "SystemDatabase":
356
368
  """Factory method to create the appropriate SystemDatabase implementation based on URL."""
@@ -363,6 +375,7 @@ class SystemDatabase(ABC):
363
375
  engine=engine,
364
376
  schema=schema,
365
377
  serializer=serializer,
378
+ executor_id=executor_id,
366
379
  debug_mode=debug_mode,
367
380
  )
368
381
  else:
@@ -374,6 +387,7 @@ class SystemDatabase(ABC):
374
387
  engine=engine,
375
388
  schema=schema,
376
389
  serializer=serializer,
390
+ executor_id=executor_id,
377
391
  debug_mode=debug_mode,
378
392
  )
379
393
 
@@ -385,11 +399,32 @@ class SystemDatabase(ABC):
385
399
  engine: Optional[sa.Engine],
386
400
  schema: Optional[str],
387
401
  serializer: Serializer,
402
+ executor_id: Optional[str],
388
403
  debug_mode: bool = False,
389
404
  ):
390
405
  import sqlalchemy.dialects.postgresql as pg
391
406
  import sqlalchemy.dialects.sqlite as sq
392
407
 
408
+ # Log system database connection information
409
+ if engine:
410
+ dbos_logger.info("Initializing DBOS system database with custom engine")
411
+ else:
412
+ printable_sys_db_url = sa.make_url(system_database_url).render_as_string(
413
+ hide_password=True
414
+ )
415
+ dbos_logger.info(
416
+ f"Initializing DBOS system database with URL: {printable_sys_db_url}"
417
+ )
418
+ if system_database_url.startswith("sqlite"):
419
+ dbos_logger.info(
420
+ f"Using SQLite as a system database. The SQLite system database is for development and testing. PostgreSQL is recommended for production use."
421
+ )
422
+ else:
423
+ dbos_logger.info(
424
+ f"DBOS system database engine parameters: {engine_kwargs}"
425
+ )
426
+
427
+ # Configure and initialize the system database
393
428
  self.dialect = sq if system_database_url.startswith("sqlite") else pg
394
429
 
395
430
  self.serializer = serializer
@@ -410,6 +445,8 @@ class SystemDatabase(ABC):
410
445
 
411
446
  self.notifications_map = ThreadSafeConditionDict()
412
447
  self.workflow_events_map = ThreadSafeConditionDict()
448
+ self.executor_id = executor_id
449
+
413
450
  self._listener_thread_lock = threading.Lock()
414
451
 
415
452
  # Now we can run background processes
@@ -694,11 +731,7 @@ class SystemDatabase(ABC):
694
731
  name=status["name"],
695
732
  class_name=status["class_name"],
696
733
  config_name=status["config_name"],
697
- application_version=(
698
- application_version
699
- if application_version is not None
700
- else status["app_version"]
701
- ),
734
+ application_version=application_version,
702
735
  application_id=status["app_id"],
703
736
  authenticated_user=status["authenticated_user"],
704
737
  authenticated_roles=status["authenticated_roles"],
@@ -710,34 +743,124 @@ class SystemDatabase(ABC):
710
743
  )
711
744
 
712
745
  if start_step > 1:
746
+ # Copy the original workflow's step checkpoints
747
+ c.execute(
748
+ sa.insert(SystemSchema.operation_outputs).from_select(
749
+ [
750
+ "workflow_uuid",
751
+ "function_id",
752
+ "output",
753
+ "error",
754
+ "function_name",
755
+ "child_workflow_id",
756
+ "started_at_epoch_ms",
757
+ "completed_at_epoch_ms",
758
+ ],
759
+ sa.select(
760
+ sa.literal(forked_workflow_id).label("workflow_uuid"),
761
+ SystemSchema.operation_outputs.c.function_id,
762
+ SystemSchema.operation_outputs.c.output,
763
+ SystemSchema.operation_outputs.c.error,
764
+ SystemSchema.operation_outputs.c.function_name,
765
+ SystemSchema.operation_outputs.c.child_workflow_id,
766
+ SystemSchema.operation_outputs.c.started_at_epoch_ms,
767
+ SystemSchema.operation_outputs.c.completed_at_epoch_ms,
768
+ ).where(
769
+ (
770
+ SystemSchema.operation_outputs.c.workflow_uuid
771
+ == original_workflow_id
772
+ )
773
+ & (
774
+ SystemSchema.operation_outputs.c.function_id
775
+ < start_step
776
+ )
777
+ ),
778
+ )
779
+ )
780
+ # Copy the original workflow's events
781
+ c.execute(
782
+ sa.insert(SystemSchema.workflow_events_history).from_select(
783
+ [
784
+ "workflow_uuid",
785
+ "function_id",
786
+ "key",
787
+ "value",
788
+ ],
789
+ sa.select(
790
+ sa.literal(forked_workflow_id).label("workflow_uuid"),
791
+ SystemSchema.workflow_events_history.c.function_id,
792
+ SystemSchema.workflow_events_history.c.key,
793
+ SystemSchema.workflow_events_history.c.value,
794
+ ).where(
795
+ (
796
+ SystemSchema.workflow_events_history.c.workflow_uuid
797
+ == original_workflow_id
798
+ )
799
+ & (
800
+ SystemSchema.workflow_events_history.c.function_id
801
+ < start_step
802
+ )
803
+ ),
804
+ )
805
+ )
806
+ # Copy only the latest version of each workflow event from the history table
807
+ # (the one with the maximum function_id for each key where function_id < start_step)
808
+ weh1 = SystemSchema.workflow_events_history.alias("weh1")
809
+ weh2 = SystemSchema.workflow_events_history.alias("weh2")
713
810
 
714
- # Copy the original workflow's outputs into the forked workflow
715
- insert_stmt = sa.insert(SystemSchema.operation_outputs).from_select(
716
- [
717
- "workflow_uuid",
718
- "function_id",
719
- "output",
720
- "error",
721
- "function_name",
722
- "child_workflow_id",
723
- ],
724
- sa.select(
725
- sa.literal(forked_workflow_id).label("workflow_uuid"),
726
- SystemSchema.operation_outputs.c.function_id,
727
- SystemSchema.operation_outputs.c.output,
728
- SystemSchema.operation_outputs.c.error,
729
- SystemSchema.operation_outputs.c.function_name,
730
- SystemSchema.operation_outputs.c.child_workflow_id,
731
- ).where(
732
- (
733
- SystemSchema.operation_outputs.c.workflow_uuid
734
- == original_workflow_id
735
- )
736
- & (SystemSchema.operation_outputs.c.function_id < start_step)
737
- ),
811
+ max_function_id_subquery = (
812
+ sa.select(sa.func.max(weh2.c.function_id))
813
+ .where(
814
+ (weh2.c.workflow_uuid == original_workflow_id)
815
+ & (weh2.c.key == weh1.c.key)
816
+ & (weh2.c.function_id < start_step)
817
+ )
818
+ .scalar_subquery()
819
+ )
820
+
821
+ c.execute(
822
+ sa.insert(SystemSchema.workflow_events).from_select(
823
+ [
824
+ "workflow_uuid",
825
+ "key",
826
+ "value",
827
+ ],
828
+ sa.select(
829
+ sa.literal(forked_workflow_id).label("workflow_uuid"),
830
+ weh1.c.key,
831
+ weh1.c.value,
832
+ ).where(
833
+ (weh1.c.workflow_uuid == original_workflow_id)
834
+ & (weh1.c.function_id == max_function_id_subquery)
835
+ ),
836
+ )
837
+ )
838
+ # Copy the original workflow's streams
839
+ c.execute(
840
+ sa.insert(SystemSchema.streams).from_select(
841
+ [
842
+ "workflow_uuid",
843
+ "function_id",
844
+ "key",
845
+ "value",
846
+ "offset",
847
+ ],
848
+ sa.select(
849
+ sa.literal(forked_workflow_id).label("workflow_uuid"),
850
+ SystemSchema.streams.c.function_id,
851
+ SystemSchema.streams.c.key,
852
+ SystemSchema.streams.c.value,
853
+ SystemSchema.streams.c.offset,
854
+ ).where(
855
+ (
856
+ SystemSchema.streams.c.workflow_uuid
857
+ == original_workflow_id
858
+ )
859
+ & (SystemSchema.streams.c.function_id < start_step)
860
+ ),
861
+ )
738
862
  )
739
863
 
740
- c.execute(insert_stmt)
741
864
  return forked_workflow_id
742
865
 
743
866
  @db_retry()
@@ -828,7 +951,7 @@ class SystemDatabase(ABC):
828
951
  return workflow_id
829
952
 
830
953
  @db_retry()
831
- def await_workflow_result(self, workflow_id: str) -> Any:
954
+ def await_workflow_result(self, workflow_id: str, polling_interval: float) -> Any:
832
955
  while True:
833
956
  with self.engine.begin() as c:
834
957
  row = c.execute(
@@ -853,7 +976,7 @@ class SystemDatabase(ABC):
853
976
  raise DBOSAwaitedWorkflowCancelledError(workflow_id)
854
977
  else:
855
978
  pass # CB: I guess we're assuming the WF will show up eventually.
856
- time.sleep(1)
979
+ time.sleep(polling_interval)
857
980
 
858
981
  def get_workflows(
859
982
  self,
@@ -896,11 +1019,12 @@ class SystemDatabase(ABC):
896
1019
 
897
1020
  if input.queues_only:
898
1021
  query = sa.select(*load_columns).where(
899
- sa.and_(
900
- SystemSchema.workflow_status.c.queue_name.isnot(None),
901
- SystemSchema.workflow_status.c.status.in_(["ENQUEUED", "PENDING"]),
902
- )
1022
+ SystemSchema.workflow_status.c.queue_name.isnot(None),
903
1023
  )
1024
+ if not input.status:
1025
+ query = query.where(
1026
+ SystemSchema.workflow_status.c.status.in_(["ENQUEUED", "PENDING"])
1027
+ )
904
1028
  else:
905
1029
  query = sa.select(*load_columns)
906
1030
  if input.sort_desc:
@@ -1027,7 +1151,7 @@ class SystemDatabase(ABC):
1027
1151
  for row in rows
1028
1152
  ]
1029
1153
 
1030
- def get_workflow_steps(self, workflow_id: str) -> List[StepInfo]:
1154
+ def list_workflow_steps(self, workflow_id: str) -> List[StepInfo]:
1031
1155
  with self.engine.begin() as c:
1032
1156
  rows = c.execute(
1033
1157
  sa.select(
@@ -1038,7 +1162,9 @@ class SystemDatabase(ABC):
1038
1162
  SystemSchema.operation_outputs.c.child_workflow_id,
1039
1163
  SystemSchema.operation_outputs.c.started_at_epoch_ms,
1040
1164
  SystemSchema.operation_outputs.c.completed_at_epoch_ms,
1041
- ).where(SystemSchema.operation_outputs.c.workflow_uuid == workflow_id)
1165
+ )
1166
+ .where(SystemSchema.operation_outputs.c.workflow_uuid == workflow_id)
1167
+ .order_by(SystemSchema.operation_outputs.c.function_id)
1042
1168
  ).fetchall()
1043
1169
  steps = []
1044
1170
  for row in rows:
@@ -1069,17 +1195,44 @@ class SystemDatabase(ABC):
1069
1195
  error = result["error"]
1070
1196
  output = result["output"]
1071
1197
  assert error is None or output is None, "Only one of error or output can be set"
1072
- sql = sa.insert(SystemSchema.operation_outputs).values(
1073
- workflow_uuid=result["workflow_uuid"],
1074
- function_id=result["function_id"],
1075
- function_name=result["function_name"],
1076
- started_at_epoch_ms=result["started_at_epoch_ms"],
1077
- completed_at_epoch_ms=int(time.time() * 1000),
1078
- output=output,
1079
- error=error,
1080
- )
1198
+
1199
+ # Check if the executor ID belong to another process.
1200
+ # Reset it to this process's executor ID if so.
1201
+ wf_executor_id_row = conn.execute(
1202
+ sa.select(
1203
+ SystemSchema.workflow_status.c.executor_id,
1204
+ ).where(
1205
+ SystemSchema.workflow_status.c.workflow_uuid == result["workflow_uuid"]
1206
+ )
1207
+ ).fetchone()
1208
+ assert wf_executor_id_row is not None
1209
+ wf_executor_id = wf_executor_id_row[0]
1210
+ if self.executor_id is not None and wf_executor_id != self.executor_id:
1211
+ dbos_logger.debug(
1212
+ f'Resetting executor_id from {wf_executor_id} to {self.executor_id} for workflow {result["workflow_uuid"]}'
1213
+ )
1214
+ conn.execute(
1215
+ sa.update(SystemSchema.workflow_status)
1216
+ .values(executor_id=self.executor_id)
1217
+ .where(
1218
+ SystemSchema.workflow_status.c.workflow_uuid
1219
+ == result["workflow_uuid"]
1220
+ )
1221
+ )
1222
+
1223
+ # Record the outcome, throwing DBOSWorkflowConflictIDError if it is already present
1081
1224
  try:
1082
- conn.execute(sql)
1225
+ conn.execute(
1226
+ sa.insert(SystemSchema.operation_outputs).values(
1227
+ workflow_uuid=result["workflow_uuid"],
1228
+ function_id=result["function_id"],
1229
+ function_name=result["function_name"],
1230
+ started_at_epoch_ms=result["started_at_epoch_ms"],
1231
+ completed_at_epoch_ms=int(time.time() * 1000),
1232
+ output=output,
1233
+ error=error,
1234
+ )
1235
+ )
1083
1236
  except DBAPIError as dbapi_error:
1084
1237
  if self._is_unique_constraint_violation(dbapi_error):
1085
1238
  raise DBOSWorkflowConflictIDError(result["workflow_uuid"])
@@ -1168,6 +1321,7 @@ class SystemDatabase(ABC):
1168
1321
  SystemSchema.operation_outputs.c.output,
1169
1322
  SystemSchema.operation_outputs.c.error,
1170
1323
  SystemSchema.operation_outputs.c.function_name,
1324
+ SystemSchema.operation_outputs.c.child_workflow_id,
1171
1325
  ).where(
1172
1326
  (SystemSchema.operation_outputs.c.workflow_uuid == workflow_id)
1173
1327
  & (SystemSchema.operation_outputs.c.function_id == function_id)
@@ -1196,10 +1350,11 @@ class SystemDatabase(ABC):
1196
1350
  return None
1197
1351
 
1198
1352
  # Extract operation output data
1199
- output, error, recorded_function_name = (
1353
+ output, error, recorded_function_name, child_workflow_id = (
1200
1354
  operation_output_rows[0][0],
1201
1355
  operation_output_rows[0][1],
1202
1356
  operation_output_rows[0][2],
1357
+ operation_output_rows[0][3],
1203
1358
  )
1204
1359
 
1205
1360
  # If the provided and recorded function name are different, throw an exception
@@ -1214,6 +1369,7 @@ class SystemDatabase(ABC):
1214
1369
  result: RecordedResult = {
1215
1370
  "output": output,
1216
1371
  "error": error,
1372
+ "child_workflow_id": child_workflow_id,
1217
1373
  }
1218
1374
  return result
1219
1375
 
@@ -1226,31 +1382,6 @@ class SystemDatabase(ABC):
1226
1382
  workflow_id, function_id, function_name, c
1227
1383
  )
1228
1384
 
1229
- @db_retry()
1230
- def check_child_workflow(
1231
- self, workflow_uuid: str, function_id: int
1232
- ) -> Optional[str]:
1233
- sql = sa.select(
1234
- SystemSchema.operation_outputs.c.child_workflow_id,
1235
- SystemSchema.operation_outputs.c.error,
1236
- ).where(
1237
- SystemSchema.operation_outputs.c.workflow_uuid == workflow_uuid,
1238
- SystemSchema.operation_outputs.c.function_id == function_id,
1239
- )
1240
-
1241
- # If in a transaction, use the provided connection
1242
- row: Any
1243
- with self.engine.begin() as c:
1244
- row = c.execute(sql).fetchone()
1245
-
1246
- if row is None:
1247
- return None
1248
- elif row[1]:
1249
- e: Exception = self.serializer.deserialize(row[1])
1250
- raise e
1251
- else:
1252
- return str(row[0])
1253
-
1254
1385
  @db_retry()
1255
1386
  def send(
1256
1387
  self,
@@ -1503,6 +1634,19 @@ class SystemDatabase(ABC):
1503
1634
  set_={"value": self.serializer.serialize(message)},
1504
1635
  )
1505
1636
  )
1637
+ c.execute(
1638
+ self.dialect.insert(SystemSchema.workflow_events_history)
1639
+ .values(
1640
+ workflow_uuid=workflow_uuid,
1641
+ function_id=function_id,
1642
+ key=key,
1643
+ value=self.serializer.serialize(message),
1644
+ )
1645
+ .on_conflict_do_update(
1646
+ index_elements=["workflow_uuid", "key", "function_id"],
1647
+ set_={"value": self.serializer.serialize(message)},
1648
+ )
1649
+ )
1506
1650
  output: OperationResultInternal = {
1507
1651
  "workflow_uuid": workflow_uuid,
1508
1652
  "function_id": function_id,
@@ -1516,6 +1660,7 @@ class SystemDatabase(ABC):
1516
1660
  def set_event_from_step(
1517
1661
  self,
1518
1662
  workflow_uuid: str,
1663
+ function_id: int,
1519
1664
  key: str,
1520
1665
  message: Any,
1521
1666
  ) -> None:
@@ -1532,6 +1677,19 @@ class SystemDatabase(ABC):
1532
1677
  set_={"value": self.serializer.serialize(message)},
1533
1678
  )
1534
1679
  )
1680
+ c.execute(
1681
+ self.dialect.insert(SystemSchema.workflow_events_history)
1682
+ .values(
1683
+ workflow_uuid=workflow_uuid,
1684
+ function_id=function_id,
1685
+ key=key,
1686
+ value=self.serializer.serialize(message),
1687
+ )
1688
+ .on_conflict_do_update(
1689
+ index_elements=["workflow_uuid", "key", "function_id"],
1690
+ set_={"value": self.serializer.serialize(message)},
1691
+ )
1692
+ )
1535
1693
 
1536
1694
  def get_all_events(self, workflow_id: str) -> Dict[str, Any]:
1537
1695
  """
@@ -1550,7 +1708,6 @@ class SystemDatabase(ABC):
1550
1708
  SystemSchema.workflow_events.c.value,
1551
1709
  ).where(SystemSchema.workflow_events.c.workflow_uuid == workflow_id)
1552
1710
  ).fetchall()
1553
-
1554
1711
  events: Dict[str, Any] = {}
1555
1712
  for row in rows:
1556
1713
  key = row[0]
@@ -1705,10 +1862,6 @@ class SystemDatabase(ABC):
1705
1862
  sa.select(sa.func.count())
1706
1863
  .select_from(SystemSchema.workflow_status)
1707
1864
  .where(SystemSchema.workflow_status.c.queue_name == queue.name)
1708
- .where(
1709
- SystemSchema.workflow_status.c.queue_partition_key
1710
- == queue_partition_key
1711
- )
1712
1865
  .where(
1713
1866
  SystemSchema.workflow_status.c.status
1714
1867
  != WorkflowStatusString.ENQUEUED.value
@@ -1718,6 +1871,11 @@ class SystemDatabase(ABC):
1718
1871
  > start_time_ms - limiter_period_ms
1719
1872
  )
1720
1873
  )
1874
+ if queue_partition_key is not None:
1875
+ query = query.where(
1876
+ SystemSchema.workflow_status.c.queue_partition_key
1877
+ == queue_partition_key
1878
+ )
1721
1879
  num_recent_queries = c.execute(query).fetchone()[0] # type: ignore
1722
1880
  if num_recent_queries >= queue.limiter["limit"]:
1723
1881
  return []
@@ -1733,16 +1891,17 @@ class SystemDatabase(ABC):
1733
1891
  )
1734
1892
  .select_from(SystemSchema.workflow_status)
1735
1893
  .where(SystemSchema.workflow_status.c.queue_name == queue.name)
1736
- .where(
1737
- SystemSchema.workflow_status.c.queue_partition_key
1738
- == queue_partition_key
1739
- )
1740
1894
  .where(
1741
1895
  SystemSchema.workflow_status.c.status
1742
1896
  == WorkflowStatusString.PENDING.value
1743
1897
  )
1744
1898
  .group_by(SystemSchema.workflow_status.c.executor_id)
1745
1899
  )
1900
+ if queue_partition_key is not None:
1901
+ pending_tasks_query = pending_tasks_query.where(
1902
+ SystemSchema.workflow_status.c.queue_partition_key
1903
+ == queue_partition_key
1904
+ )
1746
1905
  pending_workflows = c.execute(pending_tasks_query).fetchall()
1747
1906
  pending_workflows_dict = {row[0]: row[1] for row in pending_workflows}
1748
1907
  local_pending_workflows = pending_workflows_dict.get(executor_id, 0)
@@ -1778,10 +1937,6 @@ class SystemDatabase(ABC):
1778
1937
  )
1779
1938
  .select_from(SystemSchema.workflow_status)
1780
1939
  .where(SystemSchema.workflow_status.c.queue_name == queue.name)
1781
- .where(
1782
- SystemSchema.workflow_status.c.queue_partition_key
1783
- == queue_partition_key
1784
- )
1785
1940
  .where(
1786
1941
  SystemSchema.workflow_status.c.status
1787
1942
  == WorkflowStatusString.ENQUEUED.value
@@ -1798,6 +1953,11 @@ class SystemDatabase(ABC):
1798
1953
  # to ensure all processes have a consistent view of the table.
1799
1954
  .with_for_update(skip_locked=skip_locks, nowait=(not skip_locks))
1800
1955
  )
1956
+ if queue_partition_key is not None:
1957
+ query = query.where(
1958
+ SystemSchema.workflow_status.c.queue_partition_key
1959
+ == queue_partition_key
1960
+ )
1801
1961
  if queue.priority_enabled:
1802
1962
  query = query.order_by(
1803
1963
  SystemSchema.workflow_status.c.priority.asc(),
@@ -1941,7 +2101,9 @@ class SystemDatabase(ABC):
1941
2101
  dbos_logger.error(f"Error connecting to the DBOS system database: {e}")
1942
2102
  raise
1943
2103
 
1944
- def write_stream_from_step(self, workflow_uuid: str, key: str, value: Any) -> None:
2104
+ def write_stream_from_step(
2105
+ self, workflow_uuid: str, function_id: int, key: str, value: Any
2106
+ ) -> None:
1945
2107
  """
1946
2108
  Write a key-value pair to the stream at the first unused offset.
1947
2109
  """
@@ -1971,6 +2133,7 @@ class SystemDatabase(ABC):
1971
2133
  c.execute(
1972
2134
  sa.insert(SystemSchema.streams).values(
1973
2135
  workflow_uuid=workflow_uuid,
2136
+ function_id=function_id,
1974
2137
  key=key,
1975
2138
  value=serialized_value,
1976
2139
  offset=next_offset,
@@ -2027,6 +2190,7 @@ class SystemDatabase(ABC):
2027
2190
  c.execute(
2028
2191
  sa.insert(SystemSchema.streams).values(
2029
2192
  workflow_uuid=workflow_uuid,
2193
+ function_id=function_id,
2030
2194
  key=key,
2031
2195
  value=serialized_value,
2032
2196
  offset=next_offset,
@@ -2124,3 +2288,76 @@ class SystemDatabase(ABC):
2124
2288
  return cutoff_epoch_timestamp_ms, [
2125
2289
  row[0] for row in pending_enqueued_result
2126
2290
  ]
2291
+
2292
+ def get_metrics(self, start_time: str, end_time: str) -> List[MetricData]:
2293
+ """
2294
+ Retrieve the number of workflows and steps that ran in a time range.
2295
+
2296
+ Args:
2297
+ start_time: ISO 8601 formatted start time
2298
+ end_time: ISO 8601 formatted end time
2299
+ """
2300
+ # Convert ISO 8601 times to epoch milliseconds
2301
+ start_epoch_ms = int(
2302
+ datetime.datetime.fromisoformat(start_time).timestamp() * 1000
2303
+ )
2304
+ end_epoch_ms = int(datetime.datetime.fromisoformat(end_time).timestamp() * 1000)
2305
+
2306
+ metrics: List[MetricData] = []
2307
+
2308
+ with self.engine.begin() as c:
2309
+ # Query workflow metrics
2310
+ workflow_query = (
2311
+ sa.select(
2312
+ SystemSchema.workflow_status.c.name,
2313
+ func.count(SystemSchema.workflow_status.c.workflow_uuid).label(
2314
+ "count"
2315
+ ),
2316
+ )
2317
+ .where(
2318
+ sa.and_(
2319
+ SystemSchema.workflow_status.c.created_at >= start_epoch_ms,
2320
+ SystemSchema.workflow_status.c.created_at < end_epoch_ms,
2321
+ )
2322
+ )
2323
+ .group_by(SystemSchema.workflow_status.c.name)
2324
+ )
2325
+
2326
+ workflow_results = c.execute(workflow_query).fetchall()
2327
+ for row in workflow_results:
2328
+ metrics.append(
2329
+ MetricData(
2330
+ metric_type="workflow_count",
2331
+ metric_name=row[0],
2332
+ value=row[1],
2333
+ )
2334
+ )
2335
+
2336
+ # Query step metrics
2337
+ step_query = (
2338
+ sa.select(
2339
+ SystemSchema.operation_outputs.c.function_name,
2340
+ func.count().label("count"),
2341
+ )
2342
+ .where(
2343
+ sa.and_(
2344
+ SystemSchema.operation_outputs.c.started_at_epoch_ms
2345
+ >= start_epoch_ms,
2346
+ SystemSchema.operation_outputs.c.started_at_epoch_ms
2347
+ < end_epoch_ms,
2348
+ )
2349
+ )
2350
+ .group_by(SystemSchema.operation_outputs.c.function_name)
2351
+ )
2352
+
2353
+ step_results = c.execute(step_query).fetchall()
2354
+ for row in step_results:
2355
+ metrics.append(
2356
+ MetricData(
2357
+ metric_type="step_count",
2358
+ metric_name=row[0],
2359
+ value=row[1],
2360
+ )
2361
+ )
2362
+
2363
+ return metrics