dbos 0.23.0a8__tar.gz → 0.23.0a9__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.

Potentially problematic release.


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

Files changed (95) hide show
  1. {dbos-0.23.0a8 → dbos-0.23.0a9}/PKG-INFO +1 -1
  2. dbos-0.23.0a9/dbos/__main__.py +26 -0
  3. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_app_db.py +29 -24
  4. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_core.py +45 -25
  5. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_dbos.py +15 -5
  6. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_dbos_config.py +45 -11
  7. dbos-0.23.0a9/dbos/_debug.py +45 -0
  8. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_sys_db.py +104 -39
  9. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/cli/cli.py +18 -0
  10. {dbos-0.23.0a8 → dbos-0.23.0a9}/pyproject.toml +1 -1
  11. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_config.py +38 -0
  12. dbos-0.23.0a9/tests/test_debug.py +147 -0
  13. {dbos-0.23.0a8 → dbos-0.23.0a9}/LICENSE +0 -0
  14. {dbos-0.23.0a8 → dbos-0.23.0a9}/README.md +0 -0
  15. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/__init__.py +0 -0
  16. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_admin_server.py +0 -0
  17. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_classproperty.py +0 -0
  18. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_cloudutils/authentication.py +0 -0
  19. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_cloudutils/cloudutils.py +0 -0
  20. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_cloudutils/databases.py +0 -0
  21. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_context.py +0 -0
  22. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_croniter.py +0 -0
  23. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_db_wizard.py +0 -0
  24. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_error.py +0 -0
  25. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_fastapi.py +0 -0
  26. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_flask.py +0 -0
  27. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_kafka.py +0 -0
  28. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_kafka_message.py +0 -0
  29. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_logger.py +0 -0
  30. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_migrations/env.py +0 -0
  31. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_migrations/script.py.mako +0 -0
  32. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_migrations/versions/04ca4f231047_workflow_queues_executor_id.py +0 -0
  33. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_migrations/versions/50f3227f0b4b_fix_job_queue.py +0 -0
  34. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_migrations/versions/5c361fc04708_added_system_tables.py +0 -0
  35. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_migrations/versions/a3b18ad34abe_added_triggers.py +0 -0
  36. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_migrations/versions/d76646551a6b_job_queue_limiter.py +0 -0
  37. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_migrations/versions/d76646551a6c_workflow_queue.py +0 -0
  38. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_migrations/versions/eab0cc1d9a14_job_queue.py +0 -0
  39. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_outcome.py +0 -0
  40. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_queue.py +0 -0
  41. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_recovery.py +0 -0
  42. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_registrations.py +0 -0
  43. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_request.py +0 -0
  44. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_roles.py +0 -0
  45. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_scheduler.py +0 -0
  46. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_schemas/__init__.py +0 -0
  47. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_schemas/application_database.py +0 -0
  48. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_schemas/system_database.py +0 -0
  49. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_serialization.py +0 -0
  50. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_templates/dbos-db-starter/README.md +0 -0
  51. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_templates/dbos-db-starter/__package/__init__.py +0 -0
  52. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_templates/dbos-db-starter/__package/main.py +0 -0
  53. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_templates/dbos-db-starter/__package/schema.py +0 -0
  54. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_templates/dbos-db-starter/alembic.ini +0 -0
  55. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_templates/dbos-db-starter/dbos-config.yaml.dbos +0 -0
  56. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_templates/dbos-db-starter/migrations/env.py.dbos +0 -0
  57. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_templates/dbos-db-starter/migrations/script.py.mako +0 -0
  58. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_templates/dbos-db-starter/migrations/versions/2024_07_31_180642_init.py +0 -0
  59. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_templates/dbos-db-starter/start_postgres_docker.py +0 -0
  60. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_tracer.py +0 -0
  61. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_utils.py +0 -0
  62. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/_workflow_commands.py +0 -0
  63. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/cli/_github_init.py +0 -0
  64. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/cli/_template_init.py +0 -0
  65. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/dbos-config.schema.json +0 -0
  66. {dbos-0.23.0a8 → dbos-0.23.0a9}/dbos/py.typed +0 -0
  67. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/__init__.py +0 -0
  68. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/atexit_no_ctor.py +0 -0
  69. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/atexit_no_launch.py +0 -0
  70. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/classdefs.py +0 -0
  71. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/conftest.py +0 -0
  72. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/more_classdefs.py +0 -0
  73. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/queuedworkflow.py +0 -0
  74. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_admin_server.py +0 -0
  75. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_async.py +0 -0
  76. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_classdecorators.py +0 -0
  77. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_concurrency.py +0 -0
  78. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_croniter.py +0 -0
  79. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_dbos.py +0 -0
  80. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_failures.py +0 -0
  81. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_fastapi.py +0 -0
  82. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_fastapi_roles.py +0 -0
  83. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_flask.py +0 -0
  84. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_kafka.py +0 -0
  85. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_outcome.py +0 -0
  86. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_package.py +0 -0
  87. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_queue.py +0 -0
  88. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_scheduler.py +0 -0
  89. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_schema_migration.py +0 -0
  90. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_singleton.py +0 -0
  91. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_spans.py +0 -0
  92. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_sqlalchemy.py +0 -0
  93. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_workflow_cancel.py +0 -0
  94. {dbos-0.23.0a8 → dbos-0.23.0a9}/tests/test_workflow_cmds.py +0 -0
  95. {dbos-0.23.0a8 → dbos-0.23.0a9}/version/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dbos
3
- Version: 0.23.0a8
3
+ Version: 0.23.0a9
4
4
  Summary: Ultra-lightweight durable execution in Python
5
5
  Author-Email: "DBOS, Inc." <contact@dbos.dev>
6
6
  License: MIT
@@ -0,0 +1,26 @@
1
+ import re
2
+ import sys
3
+ from typing import NoReturn, Optional, Union
4
+
5
+ from dbos.cli.cli import app
6
+
7
+
8
+ def main() -> NoReturn:
9
+ # Modify sys.argv[0] to remove script or executable extensions
10
+ sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0])
11
+
12
+ retval: Optional[Union[str, int]] = 1
13
+ try:
14
+ app()
15
+ retval = None
16
+ except SystemExit as e:
17
+ retval = e.code
18
+ except Exception as e:
19
+ print(f"Error: {e}", file=sys.stderr)
20
+ retval = 1
21
+ finally:
22
+ sys.exit(retval)
23
+
24
+
25
+ if __name__ == "__main__":
26
+ main()
@@ -27,29 +27,30 @@ class RecordedResult(TypedDict):
27
27
 
28
28
  class ApplicationDatabase:
29
29
 
30
- def __init__(self, config: ConfigFile):
30
+ def __init__(self, config: ConfigFile, *, debug_mode: bool = False):
31
31
  self.config = config
32
32
 
33
33
  app_db_name = config["database"]["app_db_name"]
34
34
 
35
35
  # If the application database does not already exist, create it
36
- postgres_db_url = sa.URL.create(
37
- "postgresql+psycopg",
38
- username=config["database"]["username"],
39
- password=config["database"]["password"],
40
- host=config["database"]["hostname"],
41
- port=config["database"]["port"],
42
- database="postgres",
43
- )
44
- postgres_db_engine = sa.create_engine(postgres_db_url)
45
- with postgres_db_engine.connect() as conn:
46
- conn.execution_options(isolation_level="AUTOCOMMIT")
47
- if not conn.execute(
48
- sa.text("SELECT 1 FROM pg_database WHERE datname=:db_name"),
49
- parameters={"db_name": app_db_name},
50
- ).scalar():
51
- conn.execute(sa.text(f"CREATE DATABASE {app_db_name}"))
52
- postgres_db_engine.dispose()
36
+ if not debug_mode:
37
+ postgres_db_url = sa.URL.create(
38
+ "postgresql+psycopg",
39
+ username=config["database"]["username"],
40
+ password=config["database"]["password"],
41
+ host=config["database"]["hostname"],
42
+ port=config["database"]["port"],
43
+ database="postgres",
44
+ )
45
+ postgres_db_engine = sa.create_engine(postgres_db_url)
46
+ with postgres_db_engine.connect() as conn:
47
+ conn.execution_options(isolation_level="AUTOCOMMIT")
48
+ if not conn.execute(
49
+ sa.text("SELECT 1 FROM pg_database WHERE datname=:db_name"),
50
+ parameters={"db_name": app_db_name},
51
+ ).scalar():
52
+ conn.execute(sa.text(f"CREATE DATABASE {app_db_name}"))
53
+ postgres_db_engine.dispose()
53
54
 
54
55
  # Create a connection pool for the application database
55
56
  app_db_url = sa.URL.create(
@@ -64,14 +65,16 @@ class ApplicationDatabase:
64
65
  app_db_url, pool_size=20, max_overflow=5, pool_timeout=30
65
66
  )
66
67
  self.sessionmaker = sessionmaker(bind=self.engine)
68
+ self.debug_mode = debug_mode
67
69
 
68
70
  # Create the dbos schema and transaction_outputs table in the application database
69
- with self.engine.begin() as conn:
70
- schema_creation_query = sa.text(
71
- f"CREATE SCHEMA IF NOT EXISTS {ApplicationSchema.schema}"
72
- )
73
- conn.execute(schema_creation_query)
74
- ApplicationSchema.metadata_obj.create_all(self.engine)
71
+ if not debug_mode:
72
+ with self.engine.begin() as conn:
73
+ schema_creation_query = sa.text(
74
+ f"CREATE SCHEMA IF NOT EXISTS {ApplicationSchema.schema}"
75
+ )
76
+ conn.execute(schema_creation_query)
77
+ ApplicationSchema.metadata_obj.create_all(self.engine)
75
78
 
76
79
  def destroy(self) -> None:
77
80
  self.engine.dispose()
@@ -100,6 +103,8 @@ class ApplicationDatabase:
100
103
  raise
101
104
 
102
105
  def record_transaction_error(self, output: TransactionResultInternal) -> None:
106
+ if self.debug_mode:
107
+ raise Exception("called record_transaction_error in debug mode")
103
108
  try:
104
109
  with self.engine.begin() as conn:
105
110
  conn.execute(
@@ -186,21 +186,31 @@ def _init_workflow(
186
186
  inputs = {"args": inputs["args"][1:], "kwargs": inputs["kwargs"]}
187
187
 
188
188
  wf_status = status["status"]
189
- if temp_wf_type != "transaction" or queue is not None:
190
- # Synchronously record the status and inputs for workflows and single-step workflows
191
- # We also have to do this for single-step workflows because of the foreign key constraint on the operation outputs table
192
- # TODO: Make this transactional (and with the queue step below)
193
- wf_status = dbos._sys_db.insert_workflow_status(
194
- status, max_recovery_attempts=max_recovery_attempts
195
- )
196
- # TODO: Modify the inputs if they were changed by `update_workflow_inputs`
197
- dbos._sys_db.update_workflow_inputs(wfid, _serialization.serialize_args(inputs))
189
+ if dbos.debug_mode:
190
+ get_status_result = dbos._sys_db.get_workflow_status(wfid)
191
+ if get_status_result is None:
192
+ raise DBOSNonExistentWorkflowError(wfid)
193
+ wf_status = get_status_result["status"]
198
194
  else:
199
- # Buffer the inputs for single-transaction workflows, but don't buffer the status
200
- dbos._sys_db.buffer_workflow_inputs(wfid, _serialization.serialize_args(inputs))
195
+ if temp_wf_type != "transaction" or queue is not None:
196
+ # Synchronously record the status and inputs for workflows and single-step workflows
197
+ # We also have to do this for single-step workflows because of the foreign key constraint on the operation outputs table
198
+ # TODO: Make this transactional (and with the queue step below)
199
+ wf_status = dbos._sys_db.insert_workflow_status(
200
+ status, max_recovery_attempts=max_recovery_attempts
201
+ )
202
+ # TODO: Modify the inputs if they were changed by `update_workflow_inputs`
203
+ dbos._sys_db.update_workflow_inputs(
204
+ wfid, _serialization.serialize_args(inputs)
205
+ )
206
+ else:
207
+ # Buffer the inputs for single-transaction workflows, but don't buffer the status
208
+ dbos._sys_db.buffer_workflow_inputs(
209
+ wfid, _serialization.serialize_args(inputs)
210
+ )
201
211
 
202
- if queue is not None and wf_status == WorkflowStatusString.ENQUEUED.value:
203
- dbos._sys_db.enqueue(wfid, queue)
212
+ if queue is not None and wf_status == WorkflowStatusString.ENQUEUED.value:
213
+ dbos._sys_db.enqueue(wfid, queue)
204
214
 
205
215
  status["status"] = wf_status
206
216
  return status
@@ -215,10 +225,11 @@ def _get_wf_invoke_func(
215
225
  output = func()
216
226
  status["status"] = "SUCCESS"
217
227
  status["output"] = _serialization.serialize(output)
218
- if status["queue_name"] is not None:
219
- queue = dbos._registry.queue_info_map[status["queue_name"]]
220
- dbos._sys_db.remove_from_queue(status["workflow_uuid"], queue)
221
- dbos._sys_db.buffer_workflow_status(status)
228
+ if not dbos.debug_mode:
229
+ if status["queue_name"] is not None:
230
+ queue = dbos._registry.queue_info_map[status["queue_name"]]
231
+ dbos._sys_db.remove_from_queue(status["workflow_uuid"], queue)
232
+ dbos._sys_db.buffer_workflow_status(status)
222
233
  return output
223
234
  except DBOSWorkflowConflictIDError:
224
235
  # Retrieve the workflow handle and wait for the result.
@@ -233,10 +244,11 @@ def _get_wf_invoke_func(
233
244
  except Exception as error:
234
245
  status["status"] = "ERROR"
235
246
  status["error"] = _serialization.serialize_exception(error)
236
- if status["queue_name"] is not None:
237
- queue = dbos._registry.queue_info_map[status["queue_name"]]
238
- dbos._sys_db.remove_from_queue(status["workflow_uuid"], queue)
239
- dbos._sys_db.update_workflow_status(status)
247
+ if not dbos.debug_mode:
248
+ if status["queue_name"] is not None:
249
+ queue = dbos._registry.queue_info_map[status["queue_name"]]
250
+ dbos._sys_db.remove_from_queue(status["workflow_uuid"], queue)
251
+ dbos._sys_db.update_workflow_status(status)
240
252
  raise
241
253
 
242
254
  return persist
@@ -422,10 +434,12 @@ def start_workflow(
422
434
 
423
435
  wf_status = status["status"]
424
436
 
425
- if (
426
- not execute_workflow
427
- or wf_status == WorkflowStatusString.ERROR.value
428
- or wf_status == WorkflowStatusString.SUCCESS.value
437
+ if not execute_workflow or (
438
+ not dbos.debug_mode
439
+ and (
440
+ wf_status == WorkflowStatusString.ERROR.value
441
+ or wf_status == WorkflowStatusString.SUCCESS.value
442
+ )
429
443
  ):
430
444
  dbos.logger.debug(
431
445
  f"Workflow {new_wf_id} already completed with status {wf_status}. Directly returning a workflow handle."
@@ -597,6 +611,10 @@ def decorate_transaction(
597
611
  ctx.function_id,
598
612
  )
599
613
  )
614
+ if dbos.debug_mode and recorded_output is None:
615
+ raise DBOSException(
616
+ "Transaction output not found in debug mode"
617
+ )
600
618
  if recorded_output:
601
619
  dbos.logger.debug(
602
620
  f"Replaying transaction, id: {ctx.function_id}, name: {attributes['name']}"
@@ -780,6 +798,8 @@ def decorate_step(
780
798
  recorded_output = dbos._sys_db.check_operation_execution(
781
799
  ctx.workflow_id, ctx.function_id
782
800
  )
801
+ if dbos.debug_mode and recorded_output is None:
802
+ raise DBOSException("Step output not found in debug mode")
783
803
  if recorded_output:
784
804
  dbos.logger.debug(
785
805
  f"Replaying step, id: {ctx.function_id}, name: {attributes['name']}"
@@ -313,6 +313,7 @@ class DBOS:
313
313
  dbos_logger.info("Initializing DBOS")
314
314
  self.config: ConfigFile = config
315
315
  self._launched: bool = False
316
+ self._debug_mode: bool = False
316
317
  self._sys_db_field: Optional[SystemDatabase] = None
317
318
  self._app_db_field: Optional[ApplicationDatabase] = None
318
319
  self._registry: DBOSRegistry = _get_or_create_dbos_registry()
@@ -380,23 +381,32 @@ class DBOS:
380
381
  rv: AdminServer = self._admin_server_field
381
382
  return rv
382
383
 
384
+ @property
385
+ def debug_mode(self) -> bool:
386
+ return self._debug_mode
387
+
383
388
  @classmethod
384
- def launch(cls) -> None:
389
+ def launch(cls, *, debug_mode: bool = False) -> None:
385
390
  if _dbos_global_instance is not None:
386
- _dbos_global_instance._launch()
391
+ _dbos_global_instance._launch(debug_mode=debug_mode)
387
392
 
388
- def _launch(self) -> None:
393
+ def _launch(self, *, debug_mode: bool = False) -> None:
389
394
  try:
390
395
  if self._launched:
391
396
  dbos_logger.warning(f"DBOS was already launched")
392
397
  return
393
398
  self._launched = True
399
+ self._debug_mode = debug_mode
394
400
  if GlobalParams.app_version == "":
395
401
  GlobalParams.app_version = self._registry.compute_app_version()
396
402
  dbos_logger.info(f"Application version: {GlobalParams.app_version}")
397
403
  self._executor_field = ThreadPoolExecutor(max_workers=64)
398
- self._sys_db_field = SystemDatabase(self.config)
399
- self._app_db_field = ApplicationDatabase(self.config)
404
+ self._sys_db_field = SystemDatabase(self.config, debug_mode=debug_mode)
405
+ self._app_db_field = ApplicationDatabase(self.config, debug_mode=debug_mode)
406
+
407
+ if debug_mode:
408
+ return
409
+
400
410
  admin_port = self.config["runtimeConfig"].get("admin_port")
401
411
  if admin_port is None:
402
412
  admin_port = 3001
@@ -192,7 +192,11 @@ def load_config(
192
192
  data = cast(ConfigFile, data)
193
193
  db_connection = load_db_connection()
194
194
  if not silent:
195
- if data["database"].get("hostname"):
195
+ if os.getenv("DBOS_DBHOST"):
196
+ print(
197
+ "[bold blue]Loading database connection parameters from debug environment variables[/bold blue]"
198
+ )
199
+ elif data["database"].get("hostname"):
196
200
  print(
197
201
  "[bold blue]Loading database connection parameters from dbos-config.yaml[/bold blue]"
198
202
  )
@@ -205,32 +209,62 @@ def load_config(
205
209
  "[bold blue]Using default database connection parameters (localhost)[/bold blue]"
206
210
  )
207
211
 
212
+ dbos_dbport: Optional[int] = None
213
+ dbport_env = os.getenv("DBOS_DBPORT")
214
+ if dbport_env:
215
+ try:
216
+ dbos_dbport = int(dbport_env)
217
+ except ValueError:
218
+ pass
219
+ dbos_dblocalsuffix: Optional[bool] = None
220
+ dblocalsuffix_env = os.getenv("DBOS_DBLOCALSUFFIX")
221
+ if dblocalsuffix_env:
222
+ try:
223
+ dbos_dblocalsuffix = dblocalsuffix_env.casefold() == "true".casefold()
224
+ except ValueError:
225
+ pass
226
+
208
227
  data["database"]["hostname"] = (
209
- data["database"].get("hostname") or db_connection.get("hostname") or "localhost"
228
+ os.getenv("DBOS_DBHOST")
229
+ or data["database"].get("hostname")
230
+ or db_connection.get("hostname")
231
+ or "localhost"
210
232
  )
233
+
211
234
  data["database"]["port"] = (
212
- data["database"].get("port") or db_connection.get("port") or 5432
235
+ dbos_dbport or data["database"].get("port") or db_connection.get("port") or 5432
213
236
  )
214
237
  data["database"]["username"] = (
215
- data["database"].get("username") or db_connection.get("username") or "postgres"
238
+ os.getenv("DBOS_DBUSER")
239
+ or data["database"].get("username")
240
+ or db_connection.get("username")
241
+ or "postgres"
216
242
  )
217
243
  data["database"]["password"] = (
218
- data["database"].get("password")
244
+ os.getenv("DBOS_DBPASSWORD")
245
+ or data["database"].get("password")
219
246
  or db_connection.get("password")
220
247
  or os.environ.get("PGPASSWORD")
221
248
  or "dbos"
222
249
  )
223
- data["database"]["local_suffix"] = (
224
- data["database"].get("local_suffix")
225
- or db_connection.get("local_suffix")
226
- or False
227
- )
250
+
251
+ local_suffix = False
252
+ dbcon_local_suffix = db_connection.get("local_suffix")
253
+ if dbcon_local_suffix is not None:
254
+ local_suffix = dbcon_local_suffix
255
+ if data["database"].get("local_suffix") is not None:
256
+ local_suffix = data["database"].get("local_suffix")
257
+ if dbos_dblocalsuffix is not None:
258
+ local_suffix = dbos_dblocalsuffix
259
+ data["database"]["local_suffix"] = local_suffix
228
260
 
229
261
  # Configure the DBOS logger
230
262
  config_logger(data)
231
263
 
232
264
  # Check the connectivity to the database and make sure it's properly configured
233
- if use_db_wizard:
265
+ # Note, never use db wizard if the DBOS is running in debug mode (i.e. DBOS_DEBUG_WORKFLOW_ID env var is set)
266
+ debugWorkflowId = os.getenv("DBOS_DEBUG_WORKFLOW_ID")
267
+ if use_db_wizard and debugWorkflowId is None:
234
268
  data = db_wizard(data, config_file_path)
235
269
 
236
270
  if "local_suffix" in data["database"] and data["database"]["local_suffix"]:
@@ -0,0 +1,45 @@
1
+ import re
2
+ import runpy
3
+ import sys
4
+ from typing import Union
5
+
6
+ from dbos import DBOS
7
+
8
+
9
+ class PythonModule:
10
+ def __init__(self, module_name: str):
11
+ self.module_name = module_name
12
+
13
+
14
+ def debug_workflow(workflow_id: str, entrypoint: Union[str, PythonModule]) -> None:
15
+ # include the current directory (represented by empty string) in the search path
16
+ # if it not already included
17
+ if "" not in sys.path:
18
+ sys.path.insert(0, "")
19
+ if isinstance(entrypoint, str):
20
+ runpy.run_path(entrypoint)
21
+ elif isinstance(entrypoint, PythonModule):
22
+ runpy.run_module(entrypoint.module_name)
23
+ else:
24
+ raise ValueError("Invalid entrypoint type. Must be a string or PythonModule.")
25
+
26
+ DBOS.logger.info(f"Debugging workflow {workflow_id}...")
27
+ DBOS.launch(debug_mode=True)
28
+ handle = DBOS.execute_workflow_id(workflow_id)
29
+ handle.get_result()
30
+ DBOS.logger.info("Workflow Debugging complete. Exiting process.")
31
+
32
+
33
+ def parse_start_command(command: str) -> Union[str, PythonModule]:
34
+ match = re.match(r"fastapi\s+run\s+(\.?[\w/]+\.py)", command)
35
+ if match:
36
+ return match.group(1)
37
+ match = re.match(r"python3?\s+(\.?[\w/]+\.py)", command)
38
+ if match:
39
+ return match.group(1)
40
+ match = re.match(r"python3?\s+-m\s+([\w\.]+)", command)
41
+ if match:
42
+ return PythonModule(match.group(1))
43
+ raise ValueError(
44
+ "Invalid command format. Must be 'fastapi run <script>' or 'python <script>' or 'python -m <module>'"
45
+ )
@@ -156,7 +156,7 @@ _buffer_flush_interval_secs = 1.0
156
156
 
157
157
  class SystemDatabase:
158
158
 
159
- def __init__(self, config: ConfigFile):
159
+ def __init__(self, config: ConfigFile, *, debug_mode: bool = False):
160
160
  self.config = config
161
161
 
162
162
  sysdb_name = (
@@ -165,26 +165,27 @@ class SystemDatabase:
165
165
  else config["database"]["app_db_name"] + SystemSchema.sysdb_suffix
166
166
  )
167
167
 
168
- # If the system database does not already exist, create it
169
- postgres_db_url = sa.URL.create(
170
- "postgresql+psycopg",
171
- username=config["database"]["username"],
172
- password=config["database"]["password"],
173
- host=config["database"]["hostname"],
174
- port=config["database"]["port"],
175
- database="postgres",
176
- # fills the "application_name" column in pg_stat_activity
177
- query={"application_name": f"dbos_transact_{GlobalParams.executor_id}"},
178
- )
179
- engine = sa.create_engine(postgres_db_url)
180
- with engine.connect() as conn:
181
- conn.execution_options(isolation_level="AUTOCOMMIT")
182
- if not conn.execute(
183
- sa.text("SELECT 1 FROM pg_database WHERE datname=:db_name"),
184
- parameters={"db_name": sysdb_name},
185
- ).scalar():
186
- conn.execute(sa.text(f"CREATE DATABASE {sysdb_name}"))
187
- engine.dispose()
168
+ if not debug_mode:
169
+ # If the system database does not already exist, create it
170
+ postgres_db_url = sa.URL.create(
171
+ "postgresql+psycopg",
172
+ username=config["database"]["username"],
173
+ password=config["database"]["password"],
174
+ host=config["database"]["hostname"],
175
+ port=config["database"]["port"],
176
+ database="postgres",
177
+ # fills the "application_name" column in pg_stat_activity
178
+ query={"application_name": f"dbos_transact_{GlobalParams.executor_id}"},
179
+ )
180
+ engine = sa.create_engine(postgres_db_url)
181
+ with engine.connect() as conn:
182
+ conn.execution_options(isolation_level="AUTOCOMMIT")
183
+ if not conn.execute(
184
+ sa.text("SELECT 1 FROM pg_database WHERE datname=:db_name"),
185
+ parameters={"db_name": sysdb_name},
186
+ ).scalar():
187
+ conn.execute(sa.text(f"CREATE DATABASE {sysdb_name}"))
188
+ engine.dispose()
188
189
 
189
190
  system_db_url = sa.URL.create(
190
191
  "postgresql+psycopg",
@@ -203,25 +204,41 @@ class SystemDatabase:
203
204
  )
204
205
 
205
206
  # Run a schema migration for the system database
206
- migration_dir = os.path.join(
207
- os.path.dirname(os.path.realpath(__file__)), "_migrations"
208
- )
209
- alembic_cfg = Config()
210
- alembic_cfg.set_main_option("script_location", migration_dir)
211
- logging.getLogger("alembic").setLevel(logging.WARNING)
212
- # Alembic requires the % in URL-escaped parameters to itself be escaped to %%.
213
- escaped_conn_string = re.sub(
214
- r"%(?=[0-9A-Fa-f]{2})",
215
- "%%",
216
- self.engine.url.render_as_string(hide_password=False),
217
- )
218
- alembic_cfg.set_main_option("sqlalchemy.url", escaped_conn_string)
219
- try:
220
- command.upgrade(alembic_cfg, "head")
221
- except Exception as e:
222
- dbos_logger.warning(
223
- f"Exception during system database construction. This is most likely because the system database was configured using a later version of DBOS: {e}"
207
+ if not debug_mode:
208
+ migration_dir = os.path.join(
209
+ os.path.dirname(os.path.realpath(__file__)), "_migrations"
224
210
  )
211
+ alembic_cfg = Config()
212
+ alembic_cfg.set_main_option("script_location", migration_dir)
213
+ logging.getLogger("alembic").setLevel(logging.WARNING)
214
+ # Alembic requires the % in URL-escaped parameters to itself be escaped to %%.
215
+ escaped_conn_string = re.sub(
216
+ r"%(?=[0-9A-Fa-f]{2})",
217
+ "%%",
218
+ self.engine.url.render_as_string(hide_password=False),
219
+ )
220
+ alembic_cfg.set_main_option("sqlalchemy.url", escaped_conn_string)
221
+ try:
222
+ command.upgrade(alembic_cfg, "head")
223
+ except Exception as e:
224
+ dbos_logger.warning(
225
+ f"Exception during system database construction. This is most likely because the system database was configured using a later version of DBOS: {e}"
226
+ )
227
+ alembic_cfg = Config()
228
+ alembic_cfg.set_main_option("script_location", migration_dir)
229
+ # Alembic requires the % in URL-escaped parameters to itself be escaped to %%.
230
+ escaped_conn_string = re.sub(
231
+ r"%(?=[0-9A-Fa-f]{2})",
232
+ "%%",
233
+ self.engine.url.render_as_string(hide_password=False),
234
+ )
235
+ alembic_cfg.set_main_option("sqlalchemy.url", escaped_conn_string)
236
+ try:
237
+ command.upgrade(alembic_cfg, "head")
238
+ except Exception as e:
239
+ dbos_logger.warning(
240
+ f"Exception during system database construction. This is most likely because the system database was configured using a later version of DBOS: {e}"
241
+ )
225
242
 
226
243
  self.notification_conn: Optional[psycopg.connection.Connection] = None
227
244
  self.notifications_map: Dict[str, threading.Condition] = {}
@@ -237,6 +254,7 @@ class SystemDatabase:
237
254
 
238
255
  # Now we can run background processes
239
256
  self._run_background_processes = True
257
+ self._debug_mode = debug_mode
240
258
 
241
259
  # Destroy the pool when finished
242
260
  def destroy(self) -> None:
@@ -258,6 +276,8 @@ class SystemDatabase:
258
276
  *,
259
277
  max_recovery_attempts: int = DEFAULT_MAX_RECOVERY_ATTEMPTS,
260
278
  ) -> WorkflowStatuses:
279
+ if self._debug_mode:
280
+ raise Exception("called insert_workflow_status in debug mode")
261
281
  wf_status: WorkflowStatuses = status["status"]
262
282
 
263
283
  cmd = (
@@ -357,6 +377,8 @@ class SystemDatabase:
357
377
  *,
358
378
  conn: Optional[sa.Connection] = None,
359
379
  ) -> None:
380
+ if self._debug_mode:
381
+ raise Exception("called update_workflow_status in debug mode")
360
382
  wf_status: WorkflowStatuses = status["status"]
361
383
 
362
384
  cmd = (
@@ -406,6 +428,8 @@ class SystemDatabase:
406
428
  self,
407
429
  workflow_id: str,
408
430
  ) -> None:
431
+ if self._debug_mode:
432
+ raise Exception("called cancel_workflow in debug mode")
409
433
  with self.engine.begin() as c:
410
434
  # Remove the workflow from the queues table so it does not block the table
411
435
  c.execute(
@@ -426,6 +450,8 @@ class SystemDatabase:
426
450
  self,
427
451
  workflow_id: str,
428
452
  ) -> None:
453
+ if self._debug_mode:
454
+ raise Exception("called resume_workflow in debug mode")
429
455
  with self.engine.begin() as c:
430
456
  # Check the status of the workflow. If it is complete, do nothing.
431
457
  row = c.execute(
@@ -573,6 +599,9 @@ class SystemDatabase:
573
599
  def update_workflow_inputs(
574
600
  self, workflow_uuid: str, inputs: str, conn: Optional[sa.Connection] = None
575
601
  ) -> None:
602
+ if self._debug_mode:
603
+ raise Exception("called update_workflow_inputs in debug mode")
604
+
576
605
  cmd = (
577
606
  pg.insert(SystemSchema.workflow_inputs)
578
607
  .values(
@@ -737,6 +766,8 @@ class SystemDatabase:
737
766
  def record_operation_result(
738
767
  self, result: OperationResultInternal, conn: Optional[sa.Connection] = None
739
768
  ) -> None:
769
+ if self._debug_mode:
770
+ raise Exception("called record_operation_result in debug mode")
740
771
  error = result["error"]
741
772
  output = result["output"]
742
773
  assert error is None or output is None, "Only one of error or output can be set"
@@ -796,6 +827,11 @@ class SystemDatabase:
796
827
  recorded_output = self.check_operation_execution(
797
828
  workflow_uuid, function_id, conn=c
798
829
  )
830
+ if self._debug_mode and recorded_output is None:
831
+ raise Exception(
832
+ "called send in debug mode without a previous execution"
833
+ )
834
+
799
835
  if recorded_output is not None:
800
836
  dbos_logger.debug(
801
837
  f"Replaying send, id: {function_id}, destination_uuid: {destination_uuid}, topic: {topic}"
@@ -839,6 +875,8 @@ class SystemDatabase:
839
875
 
840
876
  # First, check for previous executions.
841
877
  recorded_output = self.check_operation_execution(workflow_uuid, function_id)
878
+ if self._debug_mode and recorded_output is None:
879
+ raise Exception("called recv in debug mode without a previous execution")
842
880
  if recorded_output is not None:
843
881
  dbos_logger.debug(f"Replaying recv, id: {function_id}, topic: {topic}")
844
882
  if recorded_output["output"] is not None:
@@ -988,6 +1026,9 @@ class SystemDatabase:
988
1026
  ) -> float:
989
1027
  recorded_output = self.check_operation_execution(workflow_uuid, function_id)
990
1028
  end_time: float
1029
+ if self._debug_mode and recorded_output is None:
1030
+ raise Exception("called sleep in debug mode without a previous execution")
1031
+
991
1032
  if recorded_output is not None:
992
1033
  dbos_logger.debug(f"Replaying sleep, id: {function_id}, seconds: {seconds}")
993
1034
  assert recorded_output["output"] is not None, "no recorded end time"
@@ -1022,6 +1063,10 @@ class SystemDatabase:
1022
1063
  recorded_output = self.check_operation_execution(
1023
1064
  workflow_uuid, function_id, conn=c
1024
1065
  )
1066
+ if self._debug_mode and recorded_output is None:
1067
+ raise Exception(
1068
+ "called set_event in debug mode without a previous execution"
1069
+ )
1025
1070
  if recorded_output is not None:
1026
1071
  dbos_logger.debug(f"Replaying set_event, id: {function_id}, key: {key}")
1027
1072
  return # Already sent before
@@ -1066,6 +1111,10 @@ class SystemDatabase:
1066
1111
  recorded_output = self.check_operation_execution(
1067
1112
  caller_ctx["workflow_uuid"], caller_ctx["function_id"]
1068
1113
  )
1114
+ if self._debug_mode and recorded_output is None:
1115
+ raise Exception(
1116
+ "called get_event in debug mode without a previous execution"
1117
+ )
1069
1118
  if recorded_output is not None:
1070
1119
  dbos_logger.debug(
1071
1120
  f"Replaying get_event, id: {caller_ctx['function_id']}, key: {key}"
@@ -1128,6 +1177,9 @@ class SystemDatabase:
1128
1177
  return value
1129
1178
 
1130
1179
  def _flush_workflow_status_buffer(self) -> None:
1180
+ if self._debug_mode:
1181
+ raise Exception("called _flush_workflow_status_buffer in debug mode")
1182
+
1131
1183
  """Export the workflow status buffer to the database, up to the batch size."""
1132
1184
  if len(self._workflow_status_buffer) == 0:
1133
1185
  return
@@ -1158,6 +1210,9 @@ class SystemDatabase:
1158
1210
  break
1159
1211
 
1160
1212
  def _flush_workflow_inputs_buffer(self) -> None:
1213
+ if self._debug_mode:
1214
+ raise Exception("called _flush_workflow_inputs_buffer in debug mode")
1215
+
1161
1216
  """Export the workflow inputs buffer to the database, up to the batch size."""
1162
1217
  if len(self._workflow_inputs_buffer) == 0:
1163
1218
  return
@@ -1222,6 +1277,8 @@ class SystemDatabase:
1222
1277
  )
1223
1278
 
1224
1279
  def enqueue(self, workflow_id: str, queue_name: str) -> None:
1280
+ if self._debug_mode:
1281
+ raise Exception("called enqueue in debug mode")
1225
1282
  with self.engine.begin() as c:
1226
1283
  c.execute(
1227
1284
  pg.insert(SystemSchema.workflow_queue)
@@ -1233,6 +1290,9 @@ class SystemDatabase:
1233
1290
  )
1234
1291
 
1235
1292
  def start_queued_workflows(self, queue: "Queue", executor_id: str) -> List[str]:
1293
+ if self._debug_mode:
1294
+ return []
1295
+
1236
1296
  start_time_ms = int(time.time() * 1000)
1237
1297
  if queue.limiter is not None:
1238
1298
  limiter_period_ms = int(queue.limiter["period"] * 1000)
@@ -1383,6 +1443,9 @@ class SystemDatabase:
1383
1443
  return ret_ids
1384
1444
 
1385
1445
  def remove_from_queue(self, workflow_id: str, queue: "Queue") -> None:
1446
+ if self._debug_mode:
1447
+ raise Exception("called remove_from_queue in debug mode")
1448
+
1386
1449
  with self.engine.begin() as c:
1387
1450
  if queue.limiter is None:
1388
1451
  c.execute(
@@ -1398,6 +1461,8 @@ class SystemDatabase:
1398
1461
  )
1399
1462
 
1400
1463
  def clear_queue_assignment(self, workflow_id: str) -> None:
1464
+ if self._debug_mode:
1465
+ raise Exception("called clear_queue_assignment in debug mode")
1401
1466
  with self.engine.begin() as c:
1402
1467
  c.execute(
1403
1468
  sa.update(SystemSchema.workflow_queue)
@@ -15,6 +15,8 @@ from rich import print
15
15
  from rich.prompt import IntPrompt
16
16
  from typing_extensions import Annotated
17
17
 
18
+ from dbos._debug import debug_workflow, parse_start_command
19
+
18
20
  from .. import load_config
19
21
  from .._app_db import ApplicationDatabase
20
22
  from .._dbos_config import _is_valid_app_name
@@ -232,6 +234,22 @@ def reset(
232
234
  return
233
235
 
234
236
 
237
+ @app.command(help="Replay Debug a DBOS workflow")
238
+ def debug(
239
+ workflow_id: Annotated[str, typer.Argument(help="Workflow ID to debug")],
240
+ ) -> None:
241
+ config = load_config(silent=True, use_db_wizard=False)
242
+ start = config["runtimeConfig"]["start"]
243
+ if not start:
244
+ typer.echo("No start commands found in 'dbos-config.yaml'")
245
+ raise typer.Exit(code=1)
246
+ if len(start) > 1:
247
+ typer.echo("Multiple start commands found in 'dbos-config.yaml'")
248
+ raise typer.Exit(code=1)
249
+ entrypoint = parse_start_command(start[0])
250
+ debug_workflow(workflow_id, entrypoint)
251
+
252
+
235
253
  @workflow.command(help="List workflows for your application")
236
254
  def list(
237
255
  limit: Annotated[
@@ -27,7 +27,7 @@ dependencies = [
27
27
  ]
28
28
  requires-python = ">=3.9"
29
29
  readme = "README.md"
30
- version = "0.23.0a8"
30
+ version = "0.23.0a9"
31
31
 
32
32
  [project.license]
33
33
  text = "MIT"
@@ -4,6 +4,7 @@ import os
4
4
  from unittest.mock import mock_open
5
5
 
6
6
  import pytest
7
+ import pytest_mock
7
8
 
8
9
  # Public API
9
10
  from dbos import load_config
@@ -422,3 +423,40 @@ def test_no_db_wizard(mocker):
422
423
  with pytest.raises(DBOSInitializationError) as exc_info:
423
424
  load_config(mock_filename)
424
425
  assert "Could not connect" in str(exc_info.value)
426
+
427
+
428
+ def test_debug_override(mocker: pytest_mock.MockFixture):
429
+ mock_config = """
430
+ name: "some-app"
431
+ language: "python"
432
+ runtimeConfig:
433
+ start:
434
+ - "python3 main.py"
435
+ database:
436
+ hostname: 'localhost'
437
+ port: 5432
438
+ username: 'postgres'
439
+ password: 'super-secret-password'
440
+ local_suffix: true
441
+ """
442
+ mocker.patch(
443
+ "builtins.open", side_effect=generate_mock_open(mock_filename, mock_config)
444
+ )
445
+
446
+ mocker.patch.dict(
447
+ os.environ,
448
+ {
449
+ "DBOS_DBHOST": "fakehost",
450
+ "DBOS_DBPORT": "1234",
451
+ "DBOS_DBUSER": "fakeuser",
452
+ "DBOS_DBPASSWORD": "fakepassword",
453
+ "DBOS_DBLOCALSUFFIX": "false",
454
+ },
455
+ )
456
+
457
+ configFile = load_config(mock_filename, use_db_wizard=False)
458
+ assert configFile["database"]["hostname"] == "fakehost"
459
+ assert configFile["database"]["port"] == 1234
460
+ assert configFile["database"]["username"] == "fakeuser"
461
+ assert configFile["database"]["password"] == "fakepassword"
462
+ assert configFile["database"]["local_suffix"] == False
@@ -0,0 +1,147 @@
1
+ import uuid
2
+
3
+ import pytest
4
+ import sqlalchemy as sa
5
+
6
+ from dbos import DBOS, ConfigFile, SetWorkflowID
7
+ from dbos._dbos import _get_dbos_instance
8
+ from dbos._debug import PythonModule, parse_start_command
9
+ from dbos._schemas.system_database import SystemSchema
10
+
11
+
12
+ def test_parse_fast_api_command() -> None:
13
+ command = "fastapi run app/main.py"
14
+ expected = "app/main.py"
15
+ actual = parse_start_command(command)
16
+ assert actual == expected
17
+
18
+
19
+ def test_parse_python_command() -> None:
20
+ command = "python app/main.py"
21
+ expected = "app/main.py"
22
+ actual = parse_start_command(command)
23
+ assert actual == expected
24
+
25
+
26
+ def test_parse_python3_command() -> None:
27
+ command = "python3 app/main.py"
28
+ expected = "app/main.py"
29
+ actual = parse_start_command(command)
30
+ assert actual == expected
31
+
32
+
33
+ def test_parse_python_module_command() -> None:
34
+ command = "python -m some_module"
35
+ actual = parse_start_command(command)
36
+ assert isinstance(actual, PythonModule)
37
+ assert actual.module_name == "some_module"
38
+
39
+
40
+ def test_parse_python3_module_command() -> None:
41
+ command = "python3 -m some_module"
42
+ actual = parse_start_command(command)
43
+ assert isinstance(actual, PythonModule)
44
+ assert actual.module_name == "some_module"
45
+
46
+
47
+ def get_recovery_attempts(wfuuid: str) -> int:
48
+ dbos = _get_dbos_instance()
49
+ with dbos._sys_db.engine.connect() as c:
50
+ stmt = sa.select(
51
+ SystemSchema.workflow_status.c.recovery_attempts,
52
+ SystemSchema.workflow_status.c.created_at,
53
+ SystemSchema.workflow_status.c.updated_at,
54
+ ).where(SystemSchema.workflow_status.c.workflow_uuid == wfuuid)
55
+ result = c.execute(stmt).fetchone()
56
+ assert result is not None
57
+ recovery_attempts, created_at, updated_at = result
58
+ return int(recovery_attempts)
59
+
60
+
61
+ def test_wf_debug(dbos: DBOS, config: ConfigFile) -> None:
62
+ wf_counter: int = 0
63
+ step_counter: int = 0
64
+
65
+ @DBOS.workflow()
66
+ def test_workflow(var: str) -> str:
67
+ nonlocal wf_counter
68
+ wf_counter += 1
69
+ res = test_step(var)
70
+ DBOS.logger.info("I'm test_workflow")
71
+ return res
72
+
73
+ @DBOS.step()
74
+ def test_step(var: str) -> str:
75
+ nonlocal step_counter
76
+ step_counter += 1
77
+ DBOS.logger.info("I'm test_step")
78
+ return var
79
+
80
+ wfuuid = str(uuid.uuid4())
81
+ with SetWorkflowID(wfuuid):
82
+ handle = DBOS.start_workflow(test_workflow, "test")
83
+ result = handle.get_result()
84
+ assert result == "test"
85
+ assert wf_counter == 1
86
+ assert step_counter == 1
87
+
88
+ expected_retry_attempts = get_recovery_attempts(wfuuid)
89
+
90
+ DBOS.destroy()
91
+ DBOS(config=config)
92
+ DBOS.launch(debug_mode=True)
93
+
94
+ handle = DBOS.execute_workflow_id(wfuuid)
95
+ result = handle.get_result()
96
+ assert result == "test"
97
+ assert wf_counter == 2
98
+ assert step_counter == 1
99
+
100
+ actual_retry_attempts = get_recovery_attempts(wfuuid)
101
+ assert actual_retry_attempts == expected_retry_attempts
102
+
103
+
104
+ def test_wf_debug_exception(dbos: DBOS, config: ConfigFile) -> None:
105
+ wf_counter: int = 0
106
+ step_counter: int = 0
107
+
108
+ @DBOS.workflow()
109
+ def test_workflow(var: str) -> str:
110
+ nonlocal wf_counter
111
+ wf_counter += 1
112
+ res = test_step(var)
113
+ DBOS.logger.info("I'm test_workflow")
114
+ raise Exception("test_wf_debug_exception")
115
+
116
+ @DBOS.step()
117
+ def test_step(var: str) -> str:
118
+ nonlocal step_counter
119
+ step_counter += 1
120
+ DBOS.logger.info("I'm test_step")
121
+ return var
122
+
123
+ wfuuid = str(uuid.uuid4())
124
+ with SetWorkflowID(wfuuid):
125
+ handle = DBOS.start_workflow(test_workflow, "test")
126
+ with pytest.raises(Exception) as excinfo:
127
+ handle.get_result()
128
+ assert str(excinfo.value) == "test_wf_debug_exception"
129
+
130
+ assert wf_counter == 1
131
+ assert step_counter == 1
132
+
133
+ expected_retry_attempts = get_recovery_attempts(wfuuid)
134
+
135
+ DBOS.destroy()
136
+ DBOS(config=config)
137
+ DBOS.launch(debug_mode=True)
138
+
139
+ handle = DBOS.execute_workflow_id(wfuuid)
140
+ with pytest.raises(Exception) as excinfo:
141
+ handle.get_result()
142
+ assert str(excinfo.value) == "test_wf_debug_exception"
143
+ assert wf_counter == 2
144
+ assert step_counter == 1
145
+
146
+ actual_retry_attempts = get_recovery_attempts(wfuuid)
147
+ assert actual_retry_attempts == expected_retry_attempts
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes